Merge pull request #3668 from modelscope/codex/security-doc-contracts-20260908

docs: harden authenticated gateway boundaries in security guides
This commit is contained in:
zhifu gao
2026-09-08 02:30:54 +08:00
committed by GitHub
3 changed files with 330 additions and 62 deletions
+88 -31
View File
@@ -1,6 +1,6 @@
# Security and Gateway Guide for the FunASR OpenAI-Compatible API
Use this guide before sharing the example OpenAI-compatible API with a team, workflow engine, browser UI, or service outside your laptop. The example server is intentionally small: it focuses on `/v1/audio/transcriptions` compatibility and does not enforce authentication by itself.
Use this guide before sharing the OpenAI-compatible API with a team, workflow engine, browser UI, or service outside your laptop. Neither the example `server.py` nor packaged `funasr-server` implements gateway authentication or an application-level total-upload-size limit. Their default listener is `0.0.0.0`; putting a proxy in front does not change it. A reachable backend port can bypass the gateway. This is a deployment boundary to verify, not a claim that a particular running service is exposed.
## Recommended topology
@@ -18,77 +18,117 @@ FunASR OpenAI-compatible API
Keep FunASR on a private network whenever possible. Put public TLS, identity, request limits, and audit logging at the boundary that your team already operates.
For a proxy and backend on the same host, use the [prepared checkout and isolated Python environment](README.md#quick-start). From that checkout root, start only this loopback example; the command assumes installation is already complete:
```bash
cd examples/openai_api
source ../../.venv/bin/activate
python server.py --host 127.0.0.1 --model sensevoice --device cpu --port 8000
```
- The packaged command also needs an explicit `--host 127.0.0.1` for same-host use. Do not run both servers on port 8000. Startup model/device choices and installation requirements remain those of the linked README.
- A container may need internal `0.0.0.0` for reachability. That is different from publishing its host port: the existing Compose default publishes port 8000 on all host interfaces. On the same host, use `FUNASR_HOST_PORT=127.0.0.1:8000 docker compose up --build`, or explicitly publish `127.0.0.1:8000:8000` in your own Docker command. Run Compose from `examples/openai_api` as an alternative startup method, not alongside a host server already using port 8000. This guide does not change the manifests.
- If the proxy is in another container/host, its `127.0.0.1` is not the backend. Configure a private service address and network/firewall rules that admit the proxy, not untrusted clients. `ClusterIP` is not authentication or namespace isolation; use an enforced `NetworkPolicy` and verify the actual network path.
- Test that an untrusted client cannot reach the backend directly. TLS and authentication on port 443 cannot protect a separately reachable port 8000. CORS is a browser policy, not an authentication or network-access boundary.
## Minimum controls before sharing
| Control | Why it matters | Where to enforce it |
|---|---|---|
| TLS | Audio often contains private data. | Reverse proxy, API gateway, or ingress. |
| Authentication | The local example accepts any SDK `api_key` placeholder. | Gateway bearer token, basic auth, OAuth/OIDC, or internal SSO. |
| Authentication | A local SDK `api_key` placeholder is not checked by FunASR. | Choose a matching Basic, Bearer, OAuth/OIDC, or internal SSO gateway/client pair. |
| Upload-size limits | Prevent accidental multi-GB uploads and memory pressure. | Gateway request-body limit and app-level checks. |
| Timeouts | Long recordings need longer HTTP timeouts, but stuck clients should not hang forever. | Client, proxy, and server process manager. |
| Rate limits | Protect GPU/CPU capacity from bursts. | Gateway, ingress controller, or queue worker. |
| Private `/health` | Health output is useful operational data, not a public product endpoint. | Network allowlist or private monitoring path. |
| Private operational routes | `/health`, `/v1/models` and schema/UI expose service metadata. | Deny them on the shared listener; design private monitoring access separately. |
| Logs and retention | Request metadata is useful; raw audio may be sensitive. | Central logging policy and storage lifecycle. |
The following sketches admit only exact `POST /v1/audio/transcriptions`. All other methods or paths are denied, including `/health`, `/v1/models`, `/openapi.json`, `/docs`, `/redoc` and the packaged `/asr` endpoint. A trailing slash is not the allowed path. Do not add a catch-all upstream location to make an SDK or monitoring probe work.
Rate/concurrency limits, model admission, queue budgets and upstream response redaction are **not implemented** by these sketches. Preloading one model does not restrict the example handler to that alias: a request can load another configured model. Set admission policy for shared CPU/GPU capacity. Both handlers read the upload into memory; the example also writes a temporary audio file. A compressed upload's byte size is not its decoded duration or memory cost.
## NGINX reverse proxy sketch
This is a starting point, not a complete production policy. Add your own certificates, identity provider, and secret management.
This is a Basic-auth starting point for NGINX 1.24, not a complete production policy. Provision the certificate chain and private key at the explicit paths below. Create `/etc/nginx/funasr.htpasswd` with an operator-managed `htpasswd` utility and an interactive password prompt, then restrict its permissions to the service's needs. Use `htpasswd -c /etc/nginx/funasr.htpasswd team_user` only when creating a new file; omit `-c` when updating it to avoid replacing other users. Never put the plaintext password in the configuration or command arguments. Missing credentials or an unreadable/missing password file must not grant access.
`200m` and `600s` are capacity-planning examples, not universally safe limits or a complete request deadline. In particular, proxy timeouts do not promise to cancel model work already running. Validate the actual configuration with your installed NGINX version before starting the listener.
```nginx
server {
listen 443 ssl http2;
server_name funasr.example.com;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/privkey.pem;
client_max_body_size 200m;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
location / {
# Add auth_request, basic auth, mTLS, or an API gateway policy here.
location = /v1/audio/transcriptions {
limit_except POST {
deny all;
}
auth_basic "FunASR gateway";
auth_basic_user_file /etc/nginx/funasr.htpasswd;
proxy_request_buffering on;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
proxy_set_header Authorization "";
}
location = /health {
allow 10.0.0.0/8;
deny all;
proxy_pass http://127.0.0.1:8000/health;
location / {
return 404;
}
}
```
## Caddy reverse proxy sketch
Generate the password hash with `caddy hash-password` and store the real credential outside the repository.
This sketch targets Caddy 2.11's `basic_auth` directive. Generate a password hash interactively with `caddy hash-password` and provide it as the service environment variable `FUNASR_BASIC_PASSWORD_HASH`, not as plaintext or a checked-in secret. Provision the TLS files below and restrict their permissions. Run `caddy adapt` and `caddy validate` with that environment before starting; a missing/invalid hash must not result in an unauthenticated fallback.
The explicit `route` keeps authentication before `request_body` and `reverse_proxy`. `200MiB` is intended to match the NGINX `200m` binary-unit example; validate it with the selected Caddy version. A body limit may be enforced while streaming, so an eventual 413 does **not** prove the upstream received zero bytes. Test this boundary with your actual proxy; do not claim buffering or complete-upload admission without evidence. These static-certificate recipes do not establish public DNS or automatic ACME issuance.
```caddyfile
funasr.example.com {
request_body {
max_size 200MB
tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
@transcribe {
path /v1/audio/transcriptions
method POST
}
basicauth /* {
team_user <hashed-password>
}
reverse_proxy 127.0.0.1:8000 {
transport http {
read_timeout 600s
write_timeout 600s
handle @transcribe {
route {
basic_auth {
team_user {$FUNASR_BASIC_PASSWORD_HASH}
}
request_body {
max_size 200MiB
}
reverse_proxy 127.0.0.1:8000 {
header_up -Authorization
transport http {
read_timeout 600s
write_timeout 600s
}
}
}
}
handle {
respond "Not found" 404
}
}
```
For production teams, prefer your standard SSO/OIDC gateway over shared passwords.
Both sketches remove `Authorization` before forwarding: FunASR does not need the gateway's Basic credentials. Do not mistake header forwarding for authentication. For production teams, prefer your standard SSO/OIDC gateway over shared passwords, with an explicitly matching client configuration and route policy.
## Kubernetes notes
The Kubernetes template keeps the service private with `ClusterIP`. Before adding an ingress or load balancer:
The Kubernetes template uses `ClusterIP`, not a public `LoadBalancer`. This alone does not prevent other pods or reachable hosts from calling it. Before adding an ingress or load balancer:
- Add an ingress controller or API gateway that enforces TLS, authentication, upload-size limits, and rate limits.
- Keep model cache volumes private to the namespace or node pool that owns the service.
@@ -98,7 +138,17 @@ The Kubernetes template keeps the service private with `ClusterIP`. Before addin
## Client configuration
OpenAI SDKs usually require an API key string even when FunASR does not check it locally. After you add a gateway, use the gateway-issued token as the SDK key:
**Basic gateways above:** use a Basic-capable client. This command prompts for `team_user`'s password and uploads an existing local file; it does not put the password in the command line, follow redirects or disable certificate verification. Run from a directory containing `meeting.wav`, and replace the hostname with your configured TLS gateway. It prints only the HTTP status, not the transcript or an upstream error body; verify the expected status rather than treating every curl exit code as inference success.
```bash
curl --user team_user --fail --show-error --silent \
--max-time 600 --output /dev/null --write-out 'HTTP %{http_code}\n' \
-F '[email protected]' -F 'model=sensevoice' \
-F 'response_format=verbose_json' \
https://funasr.example.com/v1/audio/transcriptions
```
**Bearer gateways only:** OpenAI SDKs usually require an API key string even when FunASR does not check it locally. The following client initialization assumes a separate gateway that accepts an `Authorization: Bearer` token. A Basic password or password hash is not a Bearer token; this SDK configuration does not authenticate to the Basic sketches above. An OIDC browser session, mTLS certificate or arbitrary SSO credential is not automatically an SDK key either. Configure the actual gateway/client scheme rather than changing FunASR's placeholder key. Install the separate client with `python -m pip install openai` in your client environment.
```python
import os
@@ -107,6 +157,8 @@ from openai import OpenAI
client = OpenAI(
base_url="https://funasr.example.com/v1",
api_key=os.environ["FUNASR_API_KEY"],
timeout=600.0,
max_retries=0,
)
```
@@ -119,14 +171,19 @@ For internal HTTP workers, read tokens from environment variables or your secret
- If transcripts may contain personal data, document retention and deletion rules before onboarding users.
- Keep public samples separate from private customer or employee audio when writing benchmark reports.
- Redact headers, tokens, file names, and speaker names before opening GitHub issues.
- The example writes temporary audio to disk and normally unlinks it after processing. That is not a no-disk or secure-erasure guarantee; handle crash leftovers, proxy buffers, disk permissions and storage retention explicitly.
- The example logs exception text and returns it as HTTP error detail. Upstream responses and application logs are not automatically redacted by either proxy sketch. Define who may see them and sanitize them before sharing; deleting an audio file does not delete its transcript or logs.
## Rollout checklist
1. Start locally and run `bash smoke_test.sh` or `python smoke_test.py`.
2. Add the gateway and verify `/health`, `/v1/models`, and `/v1/audio/transcriptions` through the gateway URL.
3. Test a small file, a large allowed file, and a file above the upload limit.
4. Confirm unauthorized requests fail before reaching FunASR.
5. Confirm timeout behavior for long audio and slow clients.
6. Record the model alias, device, image tag, FunASR version, and gateway policy.
These are acceptance checks to perform on your deployment, not a claim that your gateway or public service has passed them:
1. **Local diagnostic:** from the same prepared checkout/environment, use the unauthenticated local loopback endpoint with `python smoke_test.py meeting.wav --base-url http://127.0.0.1:8000 --model sensevoice`. The bundled Python/Bash smoke tool does not send gateway credentials. A missing audio path may download the public Chinese sample; output includes transcription and, for the Python tool, upstream error details. Keep that diagnostic private, not in shared automated logs.
2. **Authenticated upload:** through the configured TLS gateway, valid Basic credentials plus an allowed small file should reach only the intended transcription route and return the expected result. Verify filename, bytes, model and absence of the gateway `Authorization` header at a controlled upstream, without logging real credentials/audio.
3. **Unauthorized request:** a small allowed POST with missing/wrong credentials must return 401 without invoking inference. This does not prescribe which error wins when a request also exceeds a limit or is malformed. Missing/unreadable NGINX credentials or missing/invalid Caddy hash must fail closed, not fall back to unauthenticated proxying.
4. **Other routes/methods:** `/health`, `/v1/models`, `/openapi.json`, `/docs`, `/redoc`, `/asr`, trailing-slash variants and unrelated paths must remain denied (404 in these catch-all handlers). A non-POST on the exact inference path is denied too; NGINX may return 403 while Caddy uses the fallback 404. Do not open metadata routes to make a local smoke script pass through the public gateway; private monitoring needs a separate policy.
5. **Size/capacity:** check a small file, a large allowed file and an over-limit request (expected 413). Observe upstream effects separately; especially with streaming enforcement, 413 alone does not imply zero upstream bytes. Assess decoded duration, concurrency and model admission rather than treating 200m/200MiB as a capacity guarantee.
6. **Timeouts:** test slow upload, slow upstream and long inference. A client/proxy timeout is not proof that model computation was cancelled, and the sketches do not implement a complete inference scheduling/deadline policy.
7. **Bypass and recording:** from an untrusted network verify direct backend access is blocked. Record the actual model alias, device, image/FunASR/proxy versions, certificate provisioning and gateway policy. Do not infer a fresh installation, acoustic accuracy or production readiness from a small controlled HTTP fixture.
Related guides: [OpenAI API README](README.md), [client recipes](CLIENTS.md), [workflow recipes](WORKFLOWS.md), [Gradio browser demo](GRADIO.md), [Kubernetes template](kubernetes/README.md), and the repository [security policy](../../SECURITY.md).
+88 -31
View File
@@ -1,6 +1,6 @@
# FunASR OpenAI 兼容 API 安全与网关指南
当你准备把示例 OpenAI 兼容 API 提供给团队、工作流引擎、浏览器 UI 或笔记本之外的服务时,请先看这份指南。示例服务刻意保持轻量,主要关注 `/v1/audio/transcriptions` 兼容性,本身不内置鉴权策略。
当你准备把 OpenAI 兼容 API 提供给团队、工作流引擎、浏览器 UI 或笔记本之外的服务时,请先看这份指南。示例 `server.py` 和打包命令 `funasr-server` 均未实现网关鉴权或应用级上传总大小限制。它们默认监听 `0.0.0.0`;在前面加代理不会改变后端监听。可直连的后端端口可能绕过网关。这是需要验证的部署边界,不是声称某个实际运行的服务已经暴露。
## 推荐拓扑
@@ -18,77 +18,117 @@ FunASR OpenAI 兼容 API
只要条件允许,就让 FunASR 保持在私有网络中。公网 TLS、身份认证、请求限制和审计日志应放在团队已有的边界组件上。
代理与后端位于同一主机时,先按 [README 准备源码 checkout 和隔离 Python 环境](README_zh.md#快速开始)。以下命令假定已完成安装,从该 checkout 根目录启动一个 loopback 示例服务:
```bash
cd examples/openai_api
source ../../.venv/bin/activate
python server.py --host 127.0.0.1 --model sensevoice --device cpu --port 8000
```
- 同主机部署打包命令也需要显式 `--host 127.0.0.1`。不要让两个服务同时占用 8000 端口。启动模型、设备选择和安装要求仍以所链接的 README 为准。
- 容器内部可能需要监听 `0.0.0.0` 才能被访问,但这不同于宿主机端口发布:现有 Compose 默认在所有宿主机接口发布 8000。同主机场景可使用 `FUNASR_HOST_PORT=127.0.0.1:8000 docker compose up --build`,或在自己的 Docker 命令中显式发布 `127.0.0.1:8000:8000`。应从 `examples/openai_api` 目录运行 Compose,作为替代启动方式,不要与已占用 8000 的宿主机服务并用。本指南不修改部署 manifest。
- 代理位于另一容器或主机时,其 `127.0.0.1` 并不是后端。应配置私有服务地址和仅允许代理、不允许不可信客户端的网络/防火墙规则。`ClusterIP` 不是鉴权或 namespace 隔离;需要实际生效的 `NetworkPolicy`,并验证真实网络路径。
- 从不可信客户端测试无法直连后端。443 端口上的 TLS 和鉴权不能保护另一个可直连的 8000 端口。CORS 是浏览器策略,不是鉴权或网络访问边界。
## 对团队开放前的最低控制项
| 控制项 | 为什么重要 | 建议在哪里实现 |
|---|---|---|
| TLS | 音频通常包含隐私信息。 | 反向代理、API 网关或 Ingress。 |
| 鉴权 | 本地示例会接受任意 SDK `api_key` 占位符。 | 网关 Bearer token、Basic auth、OAuth/OIDC 或内部 SSO。 |
| 鉴权 | FunASR 不校验本地 SDK `api_key` 占位符。 | 选择相互匹配的 Basic、Bearer、OAuth/OIDC 或内部 SSO 网关与客户端。 |
| 上传大小限制 | 避免误传超大文件导致内存和磁盘压力。 | 网关 request body limit 和应用侧检查。 |
| 超时 | 长音频需要更长 HTTP timeout,但异常客户端不能无限挂住。 | 客户端、代理和服务进程管理器。 |
| 限流 | 防止突发请求打满 GPU/CPU。 | 网关、Ingress controller 或队列 worker。 |
| 私有 `/health` | 健康信息是运维数据,不应作为公开产品端点。 | 网络 allowlist 或私有监控路径。 |
| 私有运维路由 | `/health`、`/v1/models` 和 schema/UI 暴露服务元数据。 | 在共享监听入口拒绝,另行设计私有监控访问。 |
| 日志与留存 | 请求元数据有价值,但原始音频可能敏感。 | 集中日志策略和存储生命周期。 |
以下示例只允许精确的 `POST /v1/audio/transcriptions`。其他方法或路径均拒绝,包括 `/health`、`/v1/models`、`/openapi.json`、`/docs`、`/redoc` 和打包服务的 `/asr` 端点。末尾增加斜杠不属于允许路径。不要为了让 SDK 或监控探针通过,就增加通配转发到上游的规则。
这些示例**未实现**速率/并发限制、模型准入、队列预算和上游响应脱敏。预加载一个模型不代表示例 handler 只接受该 alias:请求可能加载另一个已配置模型。共享 CPU/GPU 必须单独设置准入策略。两个 handler 都会把上传内容读入内存,示例服务还会写临时音频文件。压缩文件的上传字节数不等于解码后的时长或内存占用。
## NGINX 反向代理示例
下面只是起点,不是完整生产策略。证书、身份系统和密钥管理需要按你的环境补齐。
这是面向 NGINX 1.24 的 Basic auth 起点,不是完整生产策略。先在下面的明确路径放置证书链与私钥。使用运维管理的 `htpasswd` 工具,通过交互式密码提示创建 `/etc/nginx/funasr.htpasswd`,并按服务需要限制文件权限。仅首次创建文件时使用 `htpasswd -c /etc/nginx/funasr.htpasswd team_user`;更新时去掉 `-c`,避免覆盖其他用户。不要把明文密码写入配置或命令参数。缺少凭据、密码文件不可读或缺失时,都不得放行。
`200m` 和 `600s` 是容量评估示例值,不是普遍安全的上限或完整请求截止时间。尤其不能把代理 timeout 当作取消已启动模型计算的承诺。启动监听前,请用实际安装的 NGINX 版本验证配置。
```nginx
server {
listen 443 ssl http2;
server_name funasr.example.com;
ssl_certificate /etc/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/privkey.pem;
client_max_body_size 200m;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
location / {
# 在这里增加 auth_request、Basic auth、mTLS 或 API 网关策略。
location = /v1/audio/transcriptions {
limit_except POST {
deny all;
}
auth_basic "FunASR gateway";
auth_basic_user_file /etc/nginx/funasr.htpasswd;
proxy_request_buffering on;
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization $http_authorization;
proxy_set_header Authorization "";
}
location = /health {
allow 10.0.0.0/8;
deny all;
proxy_pass http://127.0.0.1:8000/health;
location / {
return 404;
}
}
```
## Caddy 反向代理示例
使用 `caddy hash-password` 生成密码 hash,真实凭据不要写入仓库。
此示例面向 Caddy 2.11 的 `basic_auth` 指令。使用 `caddy hash-password` 交互式生成密码 hash,通过服务环境变量 `FUNASR_BASIC_PASSWORD_HASH` 提供,不使用明文或提交到仓库的 secret。先准备以下 TLS 文件并限制权限。启动前,在相同环境下执行 `caddy adapt` 和 `caddy validate`;hash 缺失或无效不得导致回退为无鉴权转发。
显式 `route` 保证鉴权位于 `request_body` 和 `reverse_proxy` 之前。`200MiB` 用于与 NGINX `200m` 二进制单位示例对齐,须用选定的 Caddy 版本验证。body 限额可能在流式转发过程中执行,因此最终返回 413 **不能**证明上游收到零字节。请在实际代理上测试该边界,不要在没有证据时宣称已完整缓存或在完整上传准入后才转发。这些静态证书配方不代表已验证公网 DNS 或自动 ACME 签发。
```caddyfile
funasr.example.com {
request_body {
max_size 200MB
tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
@transcribe {
path /v1/audio/transcriptions
method POST
}
basicauth /* {
team_user <hashed-password>
}
reverse_proxy 127.0.0.1:8000 {
transport http {
read_timeout 600s
write_timeout 600s
handle @transcribe {
route {
basic_auth {
team_user {$FUNASR_BASIC_PASSWORD_HASH}
}
request_body {
max_size 200MiB
}
reverse_proxy 127.0.0.1:8000 {
header_up -Authorization
transport http {
read_timeout 600s
write_timeout 600s
}
}
}
}
handle {
respond "Not found" 404
}
}
```
生产团队优先使用已有 SSO/OIDC 网关,而不是共享密码。
两个示例均在转发前移除 `Authorization`:FunASR 不需要网关的 Basic 凭据。不要把透传身份头当作完成鉴权。生产团队优先使用已有 SSO/OIDC 网关,而不是共享密码,并明确匹配的客户端配置与路由策略。
## Kubernetes 注意事项
Kubernetes 模板默认使用私有 `ClusterIP`。在增加 Ingress 或 LoadBalancer 前,请先完成:
Kubernetes 模板使用 `ClusterIP`,而非公开 `LoadBalancer`。这本身不能阻止其他 pod 或可达主机调用服务。在增加 Ingress 或 LoadBalancer 前,请先完成:
- 使用 Ingress controller 或 API 网关强制 TLS、鉴权、上传大小限制和限流。
- 模型缓存卷只暴露给拥有该服务的 namespace 或 node pool。
@@ -98,7 +138,17 @@ Kubernetes 模板默认使用私有 `ClusterIP`。在增加 Ingress 或 LoadBala
## 客户端配置
OpenAI SDK 通常要求传入 API key 字符串,即使本地 FunASR 不检查它。加上网关后,请使用网关发放的 token 作为 SDK key:
**上面的 Basic 网关:**使用支持 Basic 的客户端。以下命令交互式提示输入 `team_user` 的密码,上传已有本地文件;不会把密码放入命令行,也不跟随重定向或关闭证书校验。从包含 `meeting.wav` 的目录运行,并将域名替换为已配置 TLS 的网关。命令只输出 HTTP 状态,不输出转写内容或上游错误正文;应核对预期状态,而不是把所有 curl 退出码都当作推理成功。
```bash
curl --user team_user --fail --show-error --silent \
--max-time 600 --output /dev/null --write-out 'HTTP %{http_code}\n' \
-F '[email protected]' -F 'model=sensevoice' \
-F 'response_format=verbose_json' \
https://funasr.example.com/v1/audio/transcriptions
```
**仅适用于 Bearer 网关:**OpenAI SDK 通常要求传入 API key 字符串,即使本地 FunASR 不检查它。以下客户端初始化假设另有接受 `Authorization: Bearer` token 的网关。Basic 密码或密码 hash 不是 Bearer token;此 SDK 配置不能通过上面的 Basic 示例认证。OIDC 浏览器 session、mTLS 证书或任意 SSO 凭据也不会自动成为 SDK key。请匹配实际网关/客户端认证方式,而不是修改 FunASR 的占位 key。在客户端环境通过 `python -m pip install openai` 安装独立客户端依赖。
```python
import os
@@ -107,6 +157,8 @@ from openai import OpenAI
client = OpenAI(
base_url="https://funasr.example.com/v1",
api_key=os.environ["FUNASR_API_KEY"],
timeout=600.0,
max_retries=0,
)
```
@@ -119,14 +171,19 @@ client = OpenAI(
- 如果转写文本可能包含个人信息,请在接入用户前写清留存和删除规则。
- 写 benchmark 报告时,把公开样例和客户/员工私有音频分开。
- 打开 GitHub issue 前,先脱敏 header、token、文件名和说话人姓名。
- 示例会把临时音频写入磁盘,并在正常处理结束后 unlink。这不代表无落盘或安全擦除保证;需明确处理崩溃残留、代理缓冲、磁盘权限和存储留存。
- 示例会记录异常文本,并把它作为 HTTP error detail 返回。两份代理示例均不会自动脱敏上游响应或应用日志。应规定哪些人可以查看,并在分享前脱敏;删除音频文件不会删除其转写和日志。
## 上线检查清单
1. 先在本地启动,并运行 `bash smoke_test.sh` 或 `python smoke_test.py`。
2. 增加网关后,通过网关 URL 验证 `/health`、`/v1/models` 和 `/v1/audio/transcriptions`。
3. 分别测试小文件、允许范围内的大文件,以及超过上传限制的文件。
4. 确认未授权请求会在到达 FunASR 前失败。
5. 确认长音频和慢客户端的 timeout 行为。
6. 记录模型 alias、设备、镜像 tag、FunASR 版本和网关策略。
以下是需要在实际部署执行的验收检查,不是声称你的网关或公开服务已经通过:
1. **本地诊断:**在同一已准备好的 checkout/环境中,使用无鉴权本地 loopback 端点执行 `python smoke_test.py meeting.wav --base-url http://127.0.0.1:8000 --model sensevoice`。仓库自带 Python/Bash smoke 工具不发送网关凭据。音频路径不存在时可能下载公开中文样例;输出包含转写内容,Python 工具还会输出上游错误详情。诊断应保持私有,不要收集到共享自动化日志。
2. **认证上传:**通过已配置的 TLS 网关,正确 Basic 凭据和允许的小文件应仅到达指定转写路由并返回预期结果。在受控上游验证文件名、字节、模型以及不存在网关 `Authorization` 头,不记录真实凭据/音频。
3. **未认证请求:**允许的小型 POST 缺少或使用错误凭据时,必须返回 401 且不触发推理。这不规定同时超限或格式错误的请求应优先返回哪个错误。NGINX 凭据文件缺失/不可读、Caddy hash 缺失/无效均须失败关闭,不得回退到无鉴权代理。
4. **其他路由/方法:**`/health`、`/v1/models`、`/openapi.json`、`/docs`、`/redoc`、`/asr`、末尾带斜杠的变体和无关路径必须保持拒绝(这些兜底 handler 返回 404)。精确推理路径上的非 POST 也被拒绝;NGINX 可能返回 403,Caddy 使用兜底 404。不要为了让本地 smoke 脚本穿过公网网关而开放元数据路由;私有监控需要单独的策略。
5. **大小/容量:**检查小文件、允许的大文件与超限请求(预期 413)。单独观察上游影响;特别是流式限制时,413 本身不代表上游零字节。评估解码后时长、并发和模型准入,不把 200m/200MiB 当作容量保证。
6. **超时:**测试慢上传、慢上游和长推理。客户端/代理超时不代表已经取消模型计算,这些示例未实现完整推理调度/截止时间策略。
7. **绕过与记录:**从不可信网络确认后端不可直连。记录实际模型 alias、设备、镜像/FunASR/代理版本、证书准备过程和网关策略。不要由小型受控 HTTP fixture 推断全新环境安装、声学准确率或生产就绪。
相关文档:[OpenAI API README](README_zh.md)、[客户端配方](CLIENTS.md)、[工作流配方](WORKFLOWS_zh.md)、[Gradio 浏览器 Demo](GRADIO_zh.md)、[Kubernetes 模板](kubernetes/README_zh.md) 和仓库 [安全策略](../../SECURITY.md)。
+154
View File
@@ -1,5 +1,6 @@
"""Source-backed service docs checks without loading models or starting servers."""
import argparse
import ast
import json
from pathlib import Path
@@ -19,6 +20,7 @@ DOCS = [EXAMPLE / "CLIENTS.md", *READMES]
PACKAGED = ROOT / "funasr/bin/_server_app.py"
WORKFLOW_DOCS = [EXAMPLE / name for name in ("WORKFLOWS.md", "WORKFLOWS_zh.md")]
JAVASCRIPT_DOCS = [EXAMPLE / name for name in ("JAVASCRIPT.md", "JAVASCRIPT_zh.md")]
SECURITY_DOCS = [EXAMPLE / name for name in ("SECURITY.md", "SECURITY_zh.md")]
# Frozen from the two pre-edit workflow guides, not translated from one language.
WORKFLOW_HEADINGS = {
@@ -685,5 +687,157 @@ class JavaScriptDocsContract(unittest.TestCase):
self.assertLessEqual(set(fields), allowed)
class SecurityDocsContract(unittest.TestCase):
"""Documentation/source contracts, not proxy syntax or authentication execution."""
def test_security_preserves_headings_levels_and_existing_links(self):
expected = [
"Security and Gateway Guide for the FunASR OpenAI-Compatible API|Recommended topology|Minimum controls before sharing|NGINX reverse proxy sketch|Caddy reverse proxy sketch|Kubernetes notes|Client configuration|Data handling checklist|Rollout checklist",
"FunASR OpenAI 兼容 API 安全与网关指南|推荐拓扑|对团队开放前的最低控制项|NGINX 反向代理示例|Caddy 反向代理示例|Kubernetes 注意事项|客户端配置|数据处理清单|上线检查清单",
]
for path, legacy in zip(SECURITY_DOCS, expected):
text = path.read_text(encoding="utf-8")
prose = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
self.assertEqual(re.findall(r"(?m)^#{1,6} (.+)$", prose), legacy.split("|"))
self.assertEqual([len(level) for level in re.findall(r"(?m)^(#{1,6}) ", prose)], [1] + [2] * 8)
suffix = "_zh" if path.stem.endswith("_zh") else ""
links = re.findall(r"\[[^\]]+\]\(([^)]+)\)", text)
for link in (f"README{suffix}.md", "CLIENTS.md", f"WORKFLOWS{suffix}.md",
f"GRADIO{suffix}.md", f"kubernetes/README{suffix}.md", "../../SECURITY.md"):
self.assertIn(link, links)
for link in links:
parsed = urlsplit(link)
if parsed.scheme or parsed.netloc:
continue
target = (path.parent / unquote(parsed.path)).resolve()
self.assertIn(ROOT.resolve(), target.parents)
self.assertNotIn(".mcp-tasks", target.parts)
self.assertTrue(target.is_file(), link)
if parsed.fragment:
self.assertIn(unquote(parsed.fragment), headings(target.read_text(encoding="utf-8")), link)
def test_security_proxy_and_python_examples_are_bilingual(self):
texts = [path.read_text(encoding="utf-8") for path in SECURITY_DOCS]
for language in ("nginx", "caddyfile", "bash"):
examples = [blocks(text, language) for text in texts]
self.assertTrue(examples[0], language)
normalized = [["\n".join(line.strip() for line in code.splitlines()
if line.strip() and not line.lstrip().startswith("#"))
for code in items] for items in examples]
self.assertEqual(normalized[0], normalized[1], language)
examples = [blocks(text, "python") for text in texts]
self.assertEqual([len(items) for items in examples], [1, 1])
self.assertEqual(ast.dump(ast.parse(examples[0][0])), ast.dump(ast.parse(examples[1][0])))
def test_security_backend_binding_matches_source_and_is_explicit(self):
for source in (EXAMPLE / "server.py", ROOT / "funasr/bin/server.py"):
host = next(node for node in ast.walk(tree(source)) if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute) and node.func.attr == "add_argument"
and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value == "--host")
self.assertEqual(ast.literal_eval(next(k.value for k in host.keywords if k.arg == "default")), "0.0.0.0")
self.assertIn('${FUNASR_HOST_PORT:-8000}:8000', (EXAMPLE / "docker-compose.yml").read_text())
for path in SECURITY_DOCS:
text = path.read_text(encoding="utf-8")
prefix = text.split("```nginx", 1)[0]
for marker in ("127.0.0.1", "0.0.0.0", "FUNASR_HOST_PORT=127.0.0.1:8000", "ClusterIP"):
self.assertIn(marker, prefix)
recipes = blocks(text, "bash")
for recipe in recipes:
parsed = subprocess.run(["bash", "-n"], input=recipe, text=True, capture_output=True)
self.assertEqual(parsed.returncode, 0, parsed.stderr)
startup = next(code for code in recipes if "python server.py" in code)
self.assertIn("cd examples/openai_api", startup)
self.assertIn("source ../../.venv/bin/activate", startup)
args = shlex.split(next(line for line in startup.splitlines() if line.startswith("python server.py")))
self.assertEqual(args[args.index("--host") + 1], "127.0.0.1")
self.assertEqual(args[args.index("--model") + 1], "sensevoice")
self.assertEqual(args[args.index("--device") + 1], "cpu")
def test_security_nginx_requires_basic_auth_and_restricts_route(self):
for path in SECURITY_DOCS:
code = blocks(path.read_text(encoding="utf-8"), "nginx")[0]
code = "\n".join(line for line in code.splitlines() if not line.lstrip().startswith("#"))
for directive in ("ssl_certificate ", "ssl_certificate_key ", "auth_basic ",
"auth_basic_user_file ", "client_max_body_size "):
self.assertIn(directive, code)
realms = re.findall(r"(?m)^\s*auth_basic\s+([^;\n]+);", code)
self.assertEqual(len(realms), 1)
realm = shlex.split(realms[0])
self.assertEqual(len(realm), 1)
self.assertTrue(realm[0].strip())
self.assertNotEqual(realm[0].lower(), "off")
self.assertRegex(code, r"location\s+=\s+/v1/audio/transcriptions\s*\{")
self.assertRegex(code, r"limit_except\s+POST\s*\{\s*deny\s+all;")
self.assertRegex(code, r"location\s+/\s*\{\s*return\s+404;\s*\}")
self.assertEqual(len(re.findall(r"\bproxy_pass\b", code)), 1)
self.assertRegex(code, r"proxy_pass\s+http://127\.0\.0\.1:8000;")
self.assertIn('proxy_set_header Authorization "";', code)
self.assertNotIn("$http_authorization", code)
def test_security_caddy_orders_auth_before_body_and_proxy(self):
for path in SECURITY_DOCS:
code = blocks(path.read_text(encoding="utf-8"), "caddyfile")[0]
self.assertRegex(code, r"@transcribe\s*\{\s*path /v1/audio/transcriptions\s+method POST\s*\}")
self.assertRegex(code, r"handle @transcribe\s*\{\s*route\s*\{")
self.assertIn("basic_auth {", code)
self.assertLess(code.index("basic_auth {"), code.index("request_body {"))
self.assertLess(code.index("request_body {"), code.index("reverse_proxy "))
self.assertEqual(code.count("reverse_proxy "), 1)
self.assertIn("reverse_proxy 127.0.0.1:8000", code)
self.assertIn("header_up -Authorization", code)
self.assertRegex(code, r'handle\s*\{\s*respond "Not found" 404\s*\}')
self.assertRegex(code, r"(?m)^\s*tls /\S+ /\S+$")
def test_security_covers_registered_routes_and_unimplemented_controls(self):
routes = set()
for source in (EXAMPLE / "server.py", PACKAGED):
for node in ast.walk(tree(source)):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("get", "post"):
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
if node.args[0].value.startswith("/"):
routes.add(node.args[0].value)
self.assertEqual(routes, {"/health", "/v1/models", "/v1/audio/transcriptions", "/asr"})
for path in SECURITY_DOCS:
text = path.read_text(encoding="utf-8")
for route in routes | {"/openapi.json", "/docs", "/redoc"}:
inline_code = re.findall(r"`([^`\n]+)`", text)
self.assertTrue(any(route in value.split() for value in inline_code), route)
for marker in ("200m", "600s", "NetworkPolicy", "CORS"):
self.assertIn(marker, text)
markers = ("not implemented", "model admission", "temporary", "cancel") if path.name == "SECURITY.md" else (
"未实现", "模型准入", "临时", "取消")
for marker in markers:
self.assertIn(marker, text)
def test_security_client_distinguishes_basic_from_bearer_and_local_smoke(self):
for path in SECURITY_DOCS:
text = path.read_text(encoding="utf-8")
client_section = text.split("## Client configuration" if path.name == "SECURITY.md" else "## 客户端配置", 1)[1].split("\n## ", 1)[0]
for marker in ("Basic", "Bearer", "api_key", "OIDC", "mTLS"):
self.assertIn(marker, client_section)
curl = next(code for code in blocks(text, "bash") if "curl " in code)
args = shlex.split(curl.replace("\\\n", " "))
self.assertEqual(args[0], "curl")
# Parse only this recipe's option set; argparse also expands short-flag clusters.
parser = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
for short, long in (("-k", "--insecure"), ("-L", "--location"),
("-f", "--fail"), ("-S", "--show-error"), ("-s", "--silent")):
parser.add_argument(short, long, action="store_true")
for short, long in (("-u", "--user"), ("-m", "--max-time"),
("-o", "--output"), ("-w", "--write-out")):
parser.add_argument(short, long)
parser.add_argument("-F", "--form", action="append")
options, operands = parser.parse_known_args(args[1:])
self.assertFalse(options.insecure)
self.assertFalse(options.location)
self.assertEqual(operands, ["https://funasr.example.com/v1/audio/transcriptions"])
self.assertEqual(options.user, "team_user")
self.assertEqual(options.output, "/dev/null")
markers = ("unauthenticated local", "does not send", "not a Bearer") if path.name == "SECURITY.md" else (
"无鉴权本地", "不发送", "不是 Bearer")
for marker in markers:
self.assertIn(marker, text)
if __name__ == "__main__":
unittest.main()