外观
部署网络拓扑与配置手册
本文是跨项目部署网络的主文档,面向部署工程师、运维人员和排查跨主机链路的开发人员。 示例使用符号化主机名和占位地址;附录单独记录当前 aliyun + debian12-show + FRP 拓扑。本文只给出网络相关片段,不替代完整配置文件,也不保存真实密码、token、证书私钥或 shared secret。
默认端口以服务目录为唯一来源,字段语义以各模块配置文档为准:
1. 网络模型
1.1 三类地址不能混用
| 地址类型 | 含义 | 典型字段 | 判断方法 |
|---|---|---|---|
| 监听 / bind | 进程实际绑定的本机网卡和端口 | server.host、listenHost、listen-ip、sip-ip、rtp-ip | 在服务所在主机执行 ss -lntup 能看到 |
| 连接 / upstream | 调用方用于建立连接的地址 | freeswitch.host、audioFork.wsUrl、TTS endpoint、数据库 URL | 从调用方主机能够解析、路由并完成握手 |
| 公开 / advertised | 返回给另一节点或写入 SIP/SDP、ICE、媒体响应的地址 | callbackHost、TTS publicBaseUrl、external_sip_ip、external_rtp_ip、录音 URL、TURN external-ip | 从最终使用该地址的节点能够访问 |
0.0.0.0 只适合作为监听地址,不能作为 upstream 或公开地址。127.0.0.1 永远指向当前 进程所在主机:只有调用方和目标服务在同一网络命名空间时才可使用。NAT 主机通常用私网 IP 绑定,用公网 IP 或域名广告。
1.2 完整拓扑
1.3 呼叫控制时序
外呼由 callout-server 或管理 API 发起时,控制方向是调用方先连接 FreeSWITCH inbound ESL; 接通后 FreeSWITCH 再连接该 cell 配对的 callflow-esl outbound socket。两条 ESL 连接用途不同, 不能只开放其中一条。
1.4 WebRTC 媒体流
WSS 只承载 SIP 信令,不承载 RTP。浏览器显示 WSS 已连接不代表媒体可用;必须继续检查 ICE candidate、DTLS 状态和双方 RTP 计数。
1.5 逐连接矩阵
下表的自研服务端口均引用服务目录。生产部署可以覆盖端口, 但源、目标、防火墙和反向代理必须作为一组同步修改。
| 源 | 目标 | 协议 / 默认端口 | 长连接 | 主要配置 | 公网开放 | 检查 |
|---|---|---|---|---|---|---|
| 浏览器 | Nginx Web/API | HTTPS 443 | 否 | VITE_*_API_BASE_URL、Nginx location | 是 | 页面与 GET /api/... |
| 浏览器 | Nginx → FreeSWITCH | SIP over WSS 443 → 7443 | 是 | Sofia wss-binding、Nginx Upgrade | 仅 Nginx 入口 | SIP REGISTER |
| 浏览器 | FreeSWITCH Verto | JSON-RPC over WS/WSS 8081 / 8082 | 是 | verto.conf.xml、WebRTC 视频通话 | 可按需代理开放 | Verto Login 与视讯流 |
| SIP trunk / 终端 | FreeSWITCH | SIP UDP/TCP/TLS,现场定义 | 是 | Sofia profile、gateway、realm | 按来源限制 | sofia status |
| 浏览器 | coturn | STUN/TURN UDP+TCP 3478 | 会话期 | 两个后端 turn.* | 是 | authenticated allocation |
| 浏览器 / coturn | FreeSWITCH | DTLS-SRTP / RTP,现场范围 | 会话期 | external_rtp_ip、switch.conf.xml | 是,UDP 范围 | getStats()、RTP 计数 |
| coturn | 浏览器 / FreeSWITCH | TURN relay UDP,独立范围 | 会话期 | min-port / max-port | 是,UDP 范围 | relay address |
| FreeSWITCH | callflow-esl | outbound ESL TCP 9911 | 通话期 | dialplan socket、esl-server.* | 否 | 建立真实呼叫 |
| callflow-esl | FreeSWITCH | inbound ESL TCP 8021,部署样例另有覆盖 | 常驻 / 命令期 | freeswitch.*、event_socket.conf.xml | 否 | auth、api status |
| callout-server | 每个 FreeSWITCH | inbound ESL TCP | 常驻 / 命令期 | cells[].freeswitch.* | 否 | 事件监听与 originate |
| FreeSWITCH | Sherpa ASR (流式) | WebSocket 10096/audio | 识别期 | audioFork.wsUrl、ASR wsPath | 否 | WS 握手、partial/final |
| FreeSWITCH | FunASR ASR (2-pass 流式) | WebSocket 10099/audio | 识别期 | audioFork.wsUrl、ASR wsPath | 否 | WS 握手、partial/final |
| callout-server / 业务方 | FunASR ASR (离线) | HTTP 10094/asr | 否 | asrPath、/v1/audio/transcriptions | 否 | POST /asr 转写 |
| callout-server / 业务方 | Sherpa ASR (离线) | HTTP 10095/asr | 否 | asrPath、/v1/audio/transcriptions | 否 | POST /asr 转写 |
| callout-server | ASR | WebSocket 10096/audio 或 10099/audio | 回铃检测期 | ringbackDetection.wsUrl | 否 | 回铃检测日志 |
| callflow-esl | TTS | HTTP 9080 | 请求或流式响应 | tts.profiles.*.endpoint | 否 | POST /tts、POST /tts-stream |
| FreeSWITCH | TTS | HTTP(S) 媒体 URL | 播放期 | TTS server.publicBaseUrl | 仅 FS 可达即可 | curl 返回 URL |
| callflow-server | callflow-esl | HTTP 9912 | 否 | callflowEsl.baseUrl/token | 否 | GET /health、runtime API |
| callout-server | callflow-esl | HTTP 9912 | 否 | cells[].callflowEsl.* | 否 | 软电话会话代理 |
| callflow-esl | callout-server | HTTP 9920 | 否 | callout.calloutServer.* | 否 | 进度 / 结果回写 |
| callflow-esl | callai-server (NLU) | HTTP 9930 | 否 | callout.nlu.* | 否 | /health、/api/v1/nlu/capabilities |
| callflow-esl | callai-server (Chat) | HTTP/SSE 9930 | 单轮流式 | callout.chat.* | 否 | capabilities、SSE complete |
| callout-server | callai-server | HTTP/SSE 9930 | 话术生成流式 | callai.* | 否 | capabilities、/api/v1/script/* |
| callflow-esl 业务 / callai-server | OpenAI 兼容推理后端 | HTTP /v1/chat/completions | 单轮流式 | businessConfig.*.endpoint、llm.openaiUrl 或 DB callai.ai_llm_configs | 否 | /v1/models、一次推理 |
| callout-server | Emotion | HTTP 9090/analyze | 否 | emotionAnalysis.endpoint | 否 | /health、POST /analyze |
| Bun 服务 / FreeSWITCH | PostgreSQL | TCP 5432 | 连接池 | db.url、databases.*.url、DSN | 否 | pg_isready / SQL |
| callflow-esl / callout-server | Redis | TCP 6379 | 常驻 | redis.url | 否 | PING |
| 浏览器 | Nginx 录音 / 情绪音频 | HTTPS 443 | 否 | media.*BaseUrl、Nginx alias | 可只读开放 | HEAD / GET 文件 |
2. 部署场景
2.1 通用主机名
以下名称用于所有通用示例。内部 DNS 应返回私网地址,公网域名只用于浏览器、SIP/SDP、TURN 和确实需要跨边界访问的媒体。
| 名称 | 角色 |
|---|---|
edge.example.com | 前端、API 与媒体 HTTPS 入口 |
sip.example.com | WebRTC WSS、SIP realm 与 SIP/SDP 广告 |
turn.example.com | TURN control 与 relay 广告 |
fs-a.internal | FreeSWITCH cell A |
callflow-esl-a.internal | cell A 配对 callflow-esl |
asr.internal / tts.internal / emotion.internal | 本地语音服务 |
callai.internal / openai.internal | 统一 AI 决策中台与 OpenAI 兼容推理后端 |
postgres.internal / redis.internal | 数据服务 |
2.2 单机部署
所有进程位于同一主机时,内部 upstream 可使用 127.0.0.1。监听地址仍按访问者选择:只由 同机进程访问的服务绑定 127.0.0.1;需要接收外部 SIP、RTP、HTTPS 或 TURN 的组件绑定实际 网卡或 0.0.0.0,并用防火墙限制。
纯内网联调的关键地址:
json
{
"esl-server": { "host": "127.0.0.1", "port": 9911 },
"http-server": { "host": "127.0.0.1", "port": 9912, "authToken": ["<ESL_HTTP_TOKEN>"] },
"freeswitch": {
"host": "127.0.0.1",
"port": 8021,
"password": "<ESL_PASSWORD>",
"callbackHost": "127.0.0.1",
"callbackPort": 9911
},
"audioFork": { "wsUrl": "ws://127.0.0.1:10096/audio" },
"tts": {
"profiles": {
"wav": { "endpoint": "http://127.0.0.1:9080/tts" },
"shout": { "endpoint": "http://127.0.0.1:9080/tts-stream", "playbackPrefix": "shout://" }
}
}
}TTS 同机时仍需区分控制地址与返回地址。若 FreeSWITCH 同机,TTS 可广告 http://127.0.0.1:9080;只要任一播放者跨主机,就改为其可达的主机地址或 HTTPS 域名。
公网 WebRTC 在上述基础上增加:
sip.example.com:443终止 TLS 并代理到本机 Sofia WSS;external_sip_ip和external_rtp_ip广告公网 IP;- FreeSWITCH RTP 与 coturn relay 使用两个不重叠的 UDP 范围;
- 两个管理后端启用相同 TURN realm、host 和 shared secret,浏览器只获得短期凭据;
- 录音与情绪音频通过 Nginx 只读发布,目录不可执行、不可列目录。
内网联调不需要 TURN 时保持 turn.enabled=false。公网浏览器跨运营商网络时不能把 TURN 当作可选优化,必须完成真实 allocation 与呼叫验收。
2.3 分层多机部署
推荐最小放行规则:
| 边界 | 允许方向 | 最小端口 |
|---|---|---|
| 公网 → 边缘 | 入站 | 80/443 TCP、SIP 现场端口、3478 TCP/UDP、FS RTP UDP 范围、TURN relay UDP 范围 |
| 边缘 → 应用 | 出站 | callflow / callout API 端口;Web upstream 仅 Nginx 需要 |
| 边缘 → AI | 出站 | ASR WS、TTS HTTP、callai-server、OpenAI 兼容推理后端(仅业务确实直连时) |
| 应用 → 边缘 | 出站 | callflow-esl HTTP、每 cell inbound ESL、只读录音 HTTPS |
| 应用 → AI | 出站 | Emotion、callai-server |
| 边缘 / 应用 → 数据 | 出站 | PostgreSQL、Redis |
| AI → 数据 | 出站 | 默认无;只有新增明确依赖时开放 |
主机防火墙规则应同时限制源网段或源主机。禁止用“整个内部网段到所有端口”代替连接矩阵。 数据库和 Redis 若支持 TLS,跨不可信网络分别使用 postgresql TLS 参数与 rediss://。
2.4 每服务独立部署
完整内部地址表:
| 服务 | DNS 与端口 | 调用者 |
|---|---|---|
| FreeSWITCH A | fs-a.internal:<ESL_PORT> | callflow-esl A、callout-server |
| callflow-esl A outbound | callflow-esl-a.internal:9911 | FreeSWITCH A |
| callflow-esl A HTTP | callflow-esl-a.internal:9912 | 两个 API |
| callflow-server | callflow-api.internal:9913 | Nginx |
| callout-server | callout-api.internal:9920 | Nginx、callflow-esl |
| ASR | asr.internal:10096 | FreeSWITCH、callout-server |
| TTS | tts.internal:9080 | callflow-esl、FreeSWITCH |
| Emotion | emotion.internal:9090 | callout-server |
| 统一 AI 决策中台 (callai-server) | callai.internal:9930 | callflow-esl、callout-server |
| OpenAI 兼容推理后端 | openai.internal:8000 | callai-server |
| PostgreSQL | postgres.internal:5432 | FreeSWITCH 与各自研后端 |
| Redis | redis.internal:6379 | callflow-esl、callout-server |
调用方配置替换表:
| 调用方 | 字段 | 目标示例 |
|---|---|---|
| FreeSWITCH A | dialplan socket | callflow-esl-a.internal:9911 |
| callflow-esl A | freeswitch.host/port | fs-a.internal:<ESL_PORT> |
| callflow-esl A | freeswitch.callbackHost/Port | callflow-esl-a.internal:9911 |
| callflow-esl A | audioFork.wsUrl | ws://asr.internal:10096/audio |
| callflow-esl A | tts.profiles.*.endpoint | http://tts.internal:9080/... |
| callflow-esl A | businessConfig.*.endpoint | OpenAI 兼容后端 /v1/chat/completions 或 callai-server 完整 API URL |
| callflow-esl A | callout.*.baseUrl | 对应 API 内部基址 |
| callflow-esl A | db.url / redis.url | 数据层 DNS |
| callflow-server | databases.*.url | PostgreSQL DNS |
| callflow-server | callflowEsl.baseUrl | http://callflow-esl-a.internal:9912 |
| callout-server | cells[].freeswitch.* | 对应 cell FreeSWITCH |
| callout-server | cells[].callflowEsl.baseUrl | 对应 cell callflow-esl |
| callout-server | emotionAnalysis.endpoint | http://emotion.internal:9090/analyze |
| callout-server | callai.baseUrl | http://callai.internal:9930 |
| AI 决策中台 (callai-server) | llm.openaiUrl | http://openai.internal:8000/v1/chat/completions |
跨机部署还必须满足:
- ASR 和 TTS 流式请求的代理关闭响应缓冲,读超时覆盖最长通话轮次;
- WebSocket 代理传递
Upgrade/Connection,并保留 ASR 子协议; - FreeSWITCH 录音目录不能假设与 callout-server 共用文件系统,应由 cell 所在主机发布只读 URL;
- TTS
publicBaseUrl从 FreeSWITCH 主机测试,而不是只从 callflow-esl 测试; - 内部 DNS 设置合理 TTL,证书 SAN 覆盖实际域名;长连接不会因 DNS 变化自动迁移;
- 避免链路中重复 NAT。出现 WebSocket 随机断开或大包丢失时检查 MTU、MSS clamp 和代理超时;
- 健康检查只证明新连接可建立,部署切换前还要评估已有 ESL、WS 和流式媒体连接的排空。
2.5 多 FreeSWITCH cell 与高可用
一个 cell 固定由一台 FreeSWITCH 和配对的 callflow-esl 服务构成。cellId 会写入业务 记录并决定事件、录音与并发归属,必须稳定、唯一,投产后不要重命名。
网络相关模板:
json
{
"redis": {
"url": "rediss://<USER>:<PASSWORD>@redis.internal:6379/0",
"keyPrefix": "callout",
"lockTtlMs": 60000
},
"cells": [
{
"id": "cell-a",
"freeswitch": {
"host": "fs-a.internal",
"port": 8021,
"password": "<CELL_A_ESL_PASSWORD>",
"callbackHost": "callflow-esl-a.internal",
"callbackPort": 9911,
"originateDialStringTemplate": "user/{{destinationNumber}}",
"agentDialStringTemplate": "user/{{agentExtension}}",
"commandTimeoutMs": 15000
},
"callflowEsl": {
"baseUrl": "http://callflow-esl-a.internal:9912",
"token": "<CELL_A_HTTP_TOKEN>"
},
"media": {
"freeswitchRecordingBaseUrl": "https://media.example.com/recordings/cell-a"
},
"maxConcurrency": 100
},
{
"id": "cell-b",
"freeswitch": {
"host": "fs-b.internal",
"port": 8021,
"password": "<CELL_B_ESL_PASSWORD>",
"callbackHost": "callflow-esl-b.internal",
"callbackPort": 9911
},
"callflowEsl": {
"baseUrl": "http://callflow-esl-b.internal:9912",
"token": "<CELL_B_HTTP_TOKEN>"
},
"media": {
"freeswitchRecordingBaseUrl": "https://media.example.com/recordings/cell-b"
},
"maxConcurrency": 100
}
]
}多个 callout-server 可共享 PostgreSQL 和 Redis,并置于 HTTP 负载均衡后。每 cell inbound ESL 事件监听由 Redis 租约协调,同一时刻只应有一个消费者;这提供消费者接管能力,但不等于呼叫 无损自动迁移。至少监控租约续期、数据库连接、各 cell ESL 状态和可用并发。
以下边界需要连接归属或配对,不能直接套用普通无状态 HTTP 轮询:
- FreeSWITCH outbound socket 必须进入本 cell 的 callflow-esl;在途呼叫不能迁移到另一实例;
- inbound ESL 命令和事件必须命中承载目标 UUID 的 FreeSWITCH;
- ASR WebSocket 属于当前媒体 bug 与识别轮次,断线后需要业务恢复,不会透明切换;
- TTS
/tts-stream产生的资源和响应由创建它的 TTS 实例持有,负载均衡需粘性、共享资源存储, 或让返回 URL 精确回到原实例; - 录音 URL 必须按
attempt.cellId选择 cell 对应的媒体源; - 扩容 callflow-esl 时,共享 Redis 只协调部分运行态,不会把既有 outbound ESL socket 搬迁。
2.6 可选语音后端
主线配置使用本地 sherpa ASR/TTS。替换为云兼容网关时,只改变调用地址和 TTS 广告地址,不 改变 FreeSWITCH 到兼容接口的协议:
| 后端 | ASR | TTS | 替换规则 |
|---|---|---|---|
| 本地 sherpa | 10096 | 9080 | audioFork.wsUrl 与两个 TTS endpoint 指向本地服务 |
| 阿里云网关 | 10097 | 9081 | 指向网关 /audio、/tts、/tts-stream,校准 tts.publicBaseUrl |
| Azure 网关 | 10098 | 9082 | 同上,使用 Azure 网关端口 |
网关 tts.publicBaseUrl 同样必须从 FreeSWITCH 可达。阿里云的 appKey/token 和 Azure 的 key、 region、endpoint 只能用相应环境变量或密钥系统注入,不能写入本文、仓库配置样例或前端构建 变量。详见阿里云网关与 Azure 网关。
3. 配置调整手册
3.1 callflow-esl
下面只展示网络字段;合并时保留完整配置中的超时、业务和运行参数。
json
{
"esl-server": { "host": "0.0.0.0", "port": 9911 },
"http-server": {
"host": "0.0.0.0",
"port": 9912,
"authToken": ["<ESL_HTTP_TOKEN>"]
},
"freeswitch": {
"host": "fs-a.internal",
"port": 8021,
"password": "<ESL_PASSWORD>",
"callbackHost": "callflow-esl-a.internal",
"callbackPort": 9911
},
"audioFork": { "wsUrl": "ws://asr.internal:10096/audio" },
"tts": {
"profiles": {
"wav": {
"endpoint": "http://tts.internal:9080/tts",
"playbackTarget": "wav-url",
"fsPlaybackBaseDir": ""
},
"shout": {
"endpoint": "http://tts.internal:9080/tts-stream",
"playbackPrefix": "shout://"
}
}
},
"businessConfig": {
"llm-chat-business": {
"endpoint": "http://openai.internal:8000/v1/chat/completions",
"apiKey": "<OPENAI_API_KEY>",
"model": "<MODEL_NAME>"
},
"kb-audio-chat-business": {
"endpoint": "http://callai.internal:9930/api/v1/conversation",
"token": "<CHAT_TOKEN>"
}
},
"callout": {
"calloutServer": { "baseUrl": "http://callout-api.internal:9920", "token": "<CALLOUT_TOKEN>" },
"nlu": { "baseUrl": "http://llm.internal:9930", "token": "<NLU_TOKEN>" },
"chat": { "baseUrl": "http://llm.internal:9930", "token": "<CHAT_TOKEN>" }
},
"db": { "url": "postgres://<USER>:<PASSWORD>@postgres.internal:5432/freeswitch" },
"redis": { "url": "redis://<USER>:<PASSWORD>@redis.internal:6379/0" },
"recording": { "directory": "/srv/freeswitch/recordings" }
}逐字段按访问者判断:
| 字段 | 实际访问者 | 不能误填为 |
|---|---|---|
esl-server.host/port | FreeSWITCH → callflow-esl | FreeSWITCH 自己的回环地址 |
http-server.host/port | 两个 API / Nginx → callflow-esl | 只监听回环但调用者跨机 |
freeswitch.host/port/password | callflow-esl → FreeSWITCH inbound ESL | outbound ESL 9911 |
freeswitch.callbackHost/Port | FreeSWITCH → callflow-esl outbound ESL | callflow-esl 所在机的 127.0.0.1(跨机时) |
audioFork.wsUrl | FreeSWITCH 的 mod_audio_fork → ASR | 仅 callflow-esl 可达的地址 |
tts.profiles.*.endpoint | callflow-esl → TTS | TTS 返回给 FreeSWITCH 的媒体基址 |
recording.directory | FreeSWITCH 写文件 | callflow-esl 本机但未共享的目录 |
db.url / redis.url | callflow-esl → 数据层 | 调用方跨机时的 localhost |
audioFork.wsUrl 虽然位于 callflow-esl 配置中,真正建立 WebSocket 的是 FreeSWITCH。录音路径 也由 FreeSWITCH 写入;跨机时要使用 FreeSWITCH 本地路径或双方真实共享的挂载。
3.2 callflow-server 与前端
json
{
"server": { "host": "0.0.0.0", "port": 9913 },
"databases": {
"freeswitch": { "url": "postgres://<RO_USER>:<PASSWORD>@postgres.internal:5432/freeswitch" },
"callflow": { "url": "postgres://<USER>:<PASSWORD>@postgres.internal:5432/freeswitch?options=-c%20search_path=callflow" }
},
"callflowEsl": {
"baseUrl": "http://callflow-esl-a.internal:9912",
"token": "<ESL_HTTP_TOKEN>"
},
"turn": {
"enabled": true,
"host": "turn.example.com",
"port": 3478,
"realm": "sip.example.com",
"sharedSecret": "<INJECT_AT_RUNTIME>",
"credentialTtlSeconds": 3600
}
}callflow-server 当前没有 Redis 网络配置。前端 API 根地址在构建期注入:
bash
VITE_CALLFLOW_API_BASE_URL=/api bun run --cwd apps/callflow-webpage build
VITE_CALLOUT_API_BASE_URL=/api bun run --cwd apps/callout-webpage build
VITE_CALLAI_API_BASE_URL=/api bun run --cwd apps/callai-webpage build同域 /api 时,应为三个前端使用不同 origin,并分别代理到对应 API;同一 origin 的同一个 /api 不能同时指向多个后端。使用绝对 URL 时必须是浏览器可访问地址,并配置 HTTPS 与所需 CORS 策略。
3.3 callout-server
单 cell 可使用 cells 的单元素形式,避免从顶层兼容字段迁移到多 cell 时改变语义:
json
{
"server": {
"host": "0.0.0.0",
"port": 9920,
"serviceApi": { "authToken": "<CALLOUT_TOKEN>" }
},
"db": { "url": "postgres://<USER>:<PASSWORD>@postgres.internal:5432/freeswitch" },
"redis": {
"url": "redis://<USER>:<PASSWORD>@redis.internal:6379/0",
"keyPrefix": "callout",
"lockTtlMs": 60000
},
"emotionAnalysis": { "endpoint": "http://emotion.internal:9090/analyze" },
"nlu": {
"capabilitiesEndpoint": "http://llm.internal:9930/api/v1/nlu/capabilities",
"token": "<NLU_TOKEN>"
},
"chat": {
"capabilitiesEndpoint": "http://llm.internal:9930/api/v1/chat/capabilities",
"token": "<CHAT_TOKEN>"
},
"media": {
"emotionAudioBaseUrl": "https://media.example.com/emotion-audio"
},
"turn": {
"enabled": true,
"host": "turn.example.com",
"port": 3478,
"realm": "sip.example.com",
"sharedSecret": "<INJECT_AT_RUNTIME>"
},
"cells": [
{
"id": "cell-a",
"freeswitch": {
"host": "fs-a.internal",
"port": 8021,
"password": "<ESL_PASSWORD>",
"callbackHost": "callflow-esl-a.internal",
"callbackPort": 9911
},
"callflowEsl": {
"baseUrl": "http://callflow-esl-a.internal:9912",
"token": "<ESL_HTTP_TOKEN>"
},
"media": {
"freeswitchRecordingBaseUrl": "https://media.example.com/recordings/cell-a"
},
"maxConcurrency": 100
}
]
}ringbackDetection.wsUrl 也由 callout-server 主机直接连接 ASR。两个后端的 TURN host、port、 realm 和 shared secret 必须与 coturn 一致;shared secret 优先通过 CALLFLOW_SERVER_TURN_SHARED_SECRET 和 CALLOUT_TURN_SHARED_SECRET 注入。
3.4 ASR、TTS、Emotion、NLU 与 Chat
json
{
"server": {
"host": "0.0.0.0",
"port": 10096,
"healthPath": "/health",
"wsPath": "/audio",
"wsSubprotocol": "audio.drachtio.org"
}
}ASR 的调用者是 FreeSWITCH 和可选的 callout 回铃检测器。跨机时监听地址需覆盖目标私网网卡, 只向这些源开放。
json
{
"server": {
"listenHost": "0.0.0.0",
"listenPort": 9080,
"publicBaseUrl": "https://media.example.com/tts"
}
}TTS listenHost/listenPort 供 callflow-esl 发送控制请求;publicBaseUrl 出现在合成响应中, 由 FreeSWITCH 发起 GET。两者可以是不同地址。
json
{
"server": { "host": "0.0.0.0", "port": 9090 },
"audio": { "processedDir": "/srv/ai-voice/emotion-audio" }
}Emotion 把处理后的音频写入 audio.processedDir 并返回短文件名。Nginx 必须把该目录映射到 callout-server.media.emotionAudioBaseUrl 对应路径;Emotion 本身没有公开基址字段。
json
{
"server": { "host": "0.0.0.0", "port": 9930, "authToken": ["<CALLAI_TOKEN>"] },
"db": { "url": "postgres://postgres:postgres@postgres.internal:5432/freeswitch" },
"llm": {
"openaiUrl": "http://openai.internal:8000/v1/chat/completions",
"apiKey": "<API_KEY>",
"nlu": { "model": "qwen3.5-2b", "timeoutMs": 30000, "maxTokens": 300 },
"chat": { "model": "qwen3.5-2b", "timeoutMs": 30000 },
"script": { "model": "qwen3.6-27b-fp8", "timeoutMs": 60000, "maxTokens": 3000 }
}
}callai-server 基于 PostgreSQL callai 模式持久化维护 AI 策略与用量记录。/health 可用于探活,业务与策略管理接口支持内部 Token 鉴权(X-Callai-Internal-Token / X-Callout-Internal-Token / Authorization)以及统一用户中心会话校验。模型后端与 callai-server 不得直接暴露公网。
3.5 FreeSWITCH
inbound ESL
xml
<configuration name="event_socket.conf" description="Socket Client">
<settings>
<param name="listen-ip" value="<FS_PRIVATE_IP>"/>
<param name="listen-port" value="8021"/>
<param name="password" value="<ESL_PASSWORD>"/>
<param name="apply-inbound-acl" value="callflow-esl-inbound"/>
</settings>
</configuration>xml
<list name="callflow-esl-inbound" default="deny">
<node type="allow" cidr="<CALLFLOW_ESL_CIDR>"/>
<node type="allow" cidr="<CALLOUT_SERVER_CIDR>"/>
</list>
<list name="domains" default="deny">
<node type="allow" cidr="<NGINX_TO_SOFIA_CIDR>"/>
</list>
<list name="softphone-turn-relay" default="deny">
<node type="allow" cidr="<TURN_PUBLIC_IP>/32"/>
</list>密码不能替代网络 ACL;端口不得直接面向公网。配置中的 listen-port 必须与两个调用方的 freeswitch.port / cells[].freeswitch.port 一致。Sofia 使用的来源 ACL 必须允许实际 Nginx 上游地址,candidate ACL 必须与后端下发的 TURN 公网地址一致;不要放宽为任意公网来源。
outbound ESL 与模块
xml
<extension name="callflow-esl-socket">
<condition field="destination_number" expression="^<SERVICE_PREFIX>">
<action application="socket" data="callflow-esl-a.internal:9911 async full"/>
</condition>
</extension>xml
<load module="mod_event_socket"/>
<load module="mod_audio_fork"/>
<load module="mod_shout"/>mod_shout 仅在使用 shout:// MP3 流播放时必需;WAV 播放还应保留现场所需的 HTTP / 文件 模块。模块是否已加载可用 show modules 核对。
SIP、SDP 与 RTP
xml
<!-- vars.xml:绑定地址与广告地址分离 -->
<X-PRE-PROCESS cmd="set" data="domain=sip.example.com"/>
<X-PRE-PROCESS cmd="set" data="external_sip_ip=<PUBLIC_IP>"/>
<X-PRE-PROCESS cmd="set" data="external_rtp_ip=<PUBLIC_IP>"/>xml
<!-- Sofia profile -->
<param name="sip-ip" value="<FS_PRIVATE_IP>"/>
<param name="rtp-ip" value="<FS_PRIVATE_IP>"/>
<param name="ext-sip-ip" value="$${external_sip_ip}"/>
<param name="ext-rtp-ip" value="$${external_rtp_ip}"/>
<param name="ws-binding" value=":5066"/>
<param name="wss-binding" value=":7443"/>
<param name="local-network-acl" value="none"/>
<param name="apply-candidate-acl" value="softphone-turn-relay"/>xml
<!-- switch.conf.xml;范围按容量规划,且不与 TURN relay 重叠 -->
<param name="rtp-start-port" value="<FS_RTP_START>"/>
<param name="rtp-end-port" value="<FS_RTP_END>"/>sip-ip / rtp-ip 必须是本机可绑定地址,external_* 是写入 SIP/SDP 的公网广告地址。 NAT 后的浏览器场景应同时检查 Sofia local-network-acl、candidate ACL 和公网防火墙,不能只 修改 external_rtp_ip。
录音、数据库与证书
record-path、会议auto-record和 callflow-eslrecording.directory应落到同一套可写目录 约定;按 cell 从对应 FreeSWITCH 主机只读发布。- FreeSWITCH PostgreSQL DSN 的 host 必须从 FreeSWITCH 主机可达。不要把完整含密码 DSN 放进 公开文档、日志或命令历史。
- WSS、SIP TLS 和 DTLS 证书必须属于目标域名且处于有效期,私钥仅允许运行用户读取。仓库内 参考证书不能用于生产。
生效边界
| 变更 | 最小操作 | 备注 |
|---|---|---|
| dialplan、directory、普通 XML 变量 | reloadxml | 只影响后续匹配;用新呼叫验证 |
| ACL 内容 | reloadacl | 新连接 / 新 candidate 生效 |
| Sofia profile 的绑定、WSS、NAT 参数 | 重启对应 Sofia profile | 会影响注册与在途 SIP 会话,安排窗口 |
| 模块加载、event socket 核心监听、RTP 全局范围、底层库 | 完整重启 FreeSWITCH | 先排空通话并准备回滚 |
3.6 Nginx
以下模板展示路径关系,证书路径和安全策略按现场管理:
nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream callflow_api { server callflow-api.internal:9913; }
upstream callout_api { server callout-api.internal:9920; }
upstream asr_ws { server asr.internal:10096; }
upstream tts_http { server tts.internal:9080; }
server {
listen 443 ssl;
server_name flow.example.com;
location /api/ {
proxy_pass http://callflow_api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
root /srv/www/callflow;
try_files $uri $uri/ /index.html;
}
}
server {
listen 443 ssl;
server_name sip.example.com;
location / {
proxy_pass https://<FS_PRIVATE_IP>:7443;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}
server {
listen 443 ssl;
server_name media.example.com;
location /asr/ {
proxy_pass http://asr_ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_buffering off;
}
location /tts/ {
proxy_pass http://tts_http/;
proxy_buffering off;
proxy_read_timeout 120s;
}
location /recordings/cell-a/ {
alias /srv/freeswitch/recordings/;
limit_except GET { deny all; }
autoindex off;
}
location /emotion-audio/ {
alias /srv/ai-voice/emotion-audio/;
limit_except GET { deny all; }
autoindex off;
}
}ASR 并不需要默认公开给浏览器;只有确实跨边界且无法使用私网时才增加公网 WSS,并加入鉴权、 来源限制和容量保护。不要通过 Nginx 暴露 ESL、数据库、Redis 或 OpenAI 兼容推理后端。
3.7 coturn
ini
listening-ip=0.0.0.0
listening-port=3478
external-ip=<TURN_PUBLIC_IP>/<TURN_PRIVATE_IP>
min-port=<TURN_RELAY_START>
max-port=<TURN_RELAY_END>
realm=sip.example.com
use-auth-secret
static-auth-secret=<INJECT_IN_PRIVATE_CONFIG>
fingerprint
no-multicast-peers公网/私网映射写法用于一对一 NAT;直接绑定公网 IP 时按 coturn 版本与网络模型调整。控制端口和 整个 relay 范围都需放行,relay 范围不得与 FreeSWITCH RTP 范围重叠。生产优先配置 TLS/TURNS; 若现场暂未启用,必须至少提供 TURN/UDP 与 TURN/TCP,并使用短期 HMAC 凭据。
3.8 FRP
FRP 只应用于无法建立私网路由的应用 / AI TCP upstream。FRPS 代理端口应绑定回环或专用内网, 再由同机 Nginx / 应用消费:
toml
bindPort = 7000
proxyBindAddr = "127.0.0.1"
auth.method = "token"
auth.token = "<INJECT_FROM_SECRET_STORE>"toml
serverAddr = "<FRPS_PUBLIC_OR_PRIVATE_IP>"
serverPort = 7000
auth.method = "token"
auth.token = "<INJECT_FROM_SECRET_STORE>"
[[proxies]]
name = "asr"
type = "tcp"
localIP = "127.0.0.1"
localPort = 10096
remotePort = <LOOPBACK_UPSTREAM_PORT>不得用 FRP 承载高带宽、低抖动敏感的 FreeSWITCH RTP 或 TURN relay;公网 SIP WSS 也应在 FreeSWITCH 所在边缘主机就近终止。FRP token 只存在于私有配置,先运行配置校验,再滚动重启。
4. 安全边界
4.1 端口分区
| 分区 | 允许的典型入口 |
|---|---|
| 公网入口 | HTTPS/WSS、明确的 SIP 入口、TURN control、FreeSWITCH RTP、TURN relay |
| 跨主机内部 | outbound/inbound ESL、应用 API、ASR/TTS/Emotion、callai-server、数据库、Redis、OpenAI 兼容推理后端 |
| 仅回环 | 同机服务间 HTTP、FRPS 代理落点、未启用远程管理的诊断端口 |
以下服务不得直接暴露公网:FreeSWITCH inbound ESL、callflow-esl outbound ESL、PostgreSQL、 Redis、OpenAI 兼容推理后端、callai-server 和内部 callflow-esl HTTP API。跨主机时使用安全组、主机防火墙、ACL、 应用 token 和必要的 TLS 形成多层限制。
还需满足:
- 浏览器入口只用 HTTPS/WSS,HTTP 仅用于跳转或受控健康检查;
- SIP/SDP 的 NAT 广告地址与实际公网映射一致;
- TURN shared secret、ESL 密码、内部 token、数据库口令均由密钥系统或受限环境变量注入;
- 录音和情绪音频只读发布,禁用目录索引、上传、脚本执行和任意路径穿越;
- 公开 ASR/TTS 时配置限流、连接上限、超时和访问控制;
- 日志不得记录 Authorization、内部 token、TURN shared secret 或完整数据库 URL。
5. 部署与验收
5.1 部署前检查表
- 为每条连接填完源 IP、目标 DNS/IP、端口、协议、TLS、鉴权和负责人。
- 从最终调用者主机验证 DNS 解析,不以运维电脑的解析结果代替。
- 确认所有
127.0.0.1都是有意的同机访问,所有0.0.0.0只出现在监听字段。 - 检查 TTS、SIP/SDP、TURN、录音和情绪音频的公开地址。
- 检查 FreeSWITCH RTP 与 TURN relay 范围不重叠,安全组和主机防火墙同时放行。
- 校验证书域名、完整链、有效期和私钥权限。
- 校准代理 WebSocket Upgrade、读写超时、缓冲和请求体限制。
- 检查数据库 / Redis 备份、连接上限和跨机加密。
- 确认配置片段中的所有
<PLACEHOLDER>已在私有配置中替换,且仓库无真实凭据。 - 记录生效命令、维护窗口和逐组件回滚版本。
各场景在通用清单之外还需完成以下检查:
| 场景 | 上线前增量检查 |
|---|---|
| 单机纯内网 | 确认所有回环地址确实在同一网络命名空间;TURN 保持关闭;无管理端口意外绑定公网网卡 |
| 单机公网 WebRTC | 核对 NAT 公网映射、WSS 证书、SIP/SDP 广告、RTP 与 TURN relay 两段防火墙规则 |
| 分层多机 | 按层逐条审批源 → 目标规则;从边缘和应用层分别测试 AI / 数据层;确认没有反向开放无调用需求的端口 |
| 每服务独立 | 核对内部 DNS、证书 SAN、代理超时、MTU、录音跨机发布和 TTS 回源;准备 DNS 变更时的长连接排空方案 |
| 当前双主机 + FRP | 确认 FRPS 代理只绑定回环、12015 不属于 FRP、WSS/RTP/TURN/录音均按附录直连 |
| 多 cell / API 高可用 | 固定并登记每个 cellId;校验每 cell ESL、回调、录音 URL 和并发;验证 Redis 租约接管但不宣称在途呼叫迁移 |
5.2 逐层连通测试
在每个实际调用方主机执行,而不是只在服务本机执行:
bash
getent hosts fs-a.internal asr.internal tts.internal postgres.internal redis.internal
nc -vz fs-a.internal 8021
nc -vz callflow-esl-a.internal 9911
curl -fsS http://asr.internal:10096/health
curl -fsS http://tts.internal:9080/health
curl -fsS http://emotion.internal:9090/health
curl -fsS http://llm.internal:9930/health
curl -fsS http://llm.internal:9930/health
curl -fsS http://callflow-esl-a.internal:9912/health健康接口通过后继续验证协议:
- 用模块自带客户端连接 ASR
/audio,发送 16kHz 单声道 PCM16,确认 partial / final。 POST /tts,从 FreeSWITCH 主机 GET 返回的wavUrl;再验证/tts-stream和 MP3 Content-Type。- 使用有效内部 token 调用 NLU / Chat capabilities,并完成一次 NLU 与 SSE Chat 请求。
- 用短期 TURN 凭据分别完成 UDP 和 TCP authenticated allocation,确认 relay 端口落入规划范围。
- 从浏览器完成 WSS SIP REGISTER,检查证书、realm、401 challenge 和成功注册。
- 从 callflow-esl / callout-server 主机验证 inbound ESL 认证和命令,从 FreeSWITCH 建立 outbound socket。
- 分别访问每个 cell 的录音 URL和情绪音频 URL,确认只读、Content-Type 正确且无目录列表。
5.3 真实呼叫验收
每种部署场景至少完成一通内网呼叫和一通真实公网 / 5G WebRTC 呼叫:
- SIP 注册成功,WSS 无反复重连;
- ICE 选中预期 host 或 TURN relay candidate;
- DTLS 完成,双方 RTP 收发计数增长,双向有声;
- FreeSWITCH 建立正确 cell 的 outbound ESL;
- ASR 持续产生 partial,停顿后产生 final;
- NLU / Chat 返回,TTS WAV 与流式 MP3 均可播放;
- 通话结束后录音可通过该 cell 的 URL 访问;
- Emotion worker 能获取录音、提交分析、写回结果,处理音频 URL 可播放;
- callout 进度、最终结果、事件兜底和数据库状态一致;
- 结束后没有遗留 ASR WebSocket、TTS stream、ESL 会话或 TURN allocation。
6. 常见错误
| 现象 | 常见原因 | 修正 |
|---|---|---|
| FreeSWITCH 无法连接 outbound ESL | dialplan 使用了 FreeSWITCH 自己的 127.0.0.1,而 callflow-esl 跨机 | 改为 FreeSWITCH 可达的 callflow-esl 地址并开放 9911/TCP |
| originate 接通后无业务接管 | callbackHost 从 FreeSWITCH 不可达 | 从 FreeSWITCH 主机测试该 host/port |
| ASR 健康但通话无识别 | 只验证了 callflow-esl → ASR,实际连接者 FreeSWITCH 不可达 | 从 FreeSWITCH 验证 WS 路径、子协议和防火墙 |
| TTS 合成成功但无声 | TTS publicBaseUrl 只对 TTS 自己或 callflow-esl 可达 | 从 FreeSWITCH GET 返回 URL,修正广告地址 / 代理 |
| WSS 握手失败或很快断开 | Nginx 丢失 Upgrade / Connection,证书域名错误,超时过短 | 修正 WebSocket 代理与证书,延长读超时 |
| WSS 注册成功但无媒体 | RTP / TURN relay 未放行,SDP 广告私网 IP,ICE candidate 错误 | 对照 SDP、candidate、DTLS 和双方 RTP 计数逐层检查 |
| TURN control 可连接但 allocation 失败 | 只开了 3478,relay 范围未开放或 secret / realm 不一致 | 放行完整 relay 范围并校准两端配置 |
| 媒体偶发断流 | 代理缓冲、MTU / MSS、NAT 空闲回收或读超时 | 关闭流式缓冲,检查路径 MTU、keepalive 与超时 |
| 录音在一个 cell 可播、另一个 404 | 所有记录错误共用同一个录音 base URL | 按 cellId 配置 cells[].media.freeswitchRecordingBaseUrl |
| 情绪结果有短文件名但页面 404 | processedDir 与 Nginx alias / emotionAudioBaseUrl 不一致 | 对齐物理目录、URL 前缀和权限 |
| 内部管理端口出现在公网扫描结果 | 服务绑定全部网卡且安全组 / 防火墙缺少源限制,或 FRP 绑定公网 | 改为私网 / 回环绑定并增加源 ACL |
| FRP 上游能访问但媒体质量差 | 错把 RTP、TURN relay 或 SIP WSS 放进 FRP | 将实时媒体与边缘信令迁到公网边缘主机直连 |
附录 A:当前双主机生产拓扑
本节记录仓库当前部署参考,不是新的通用默认值。公开 IP 和域名是现有网络标识;凭据仍全部 使用占位符。实际变更前以现场配置、Debian 12 部署、 FRP 说明和 WebRTC ICE/TURN 指南交叉核对。
A.1 主机职责
| 主机 | 组件 | 网络职责 |
|---|---|---|
aliyun | FreeSWITCH(已有常驻实例)、callflow-esl、FRPS、coturn、Nginx、三个 Web 前端 SPA 与文档站 | 公网 HTTPS/WSS、SIP/RTP、TURN;通过回环 FRP 使用应用与 AI 上游;本地发布录音与 Web 前端 |
WSL / 计算主机 (/data/ai-voice) | callflow-server、callout-server、callai-server、ASR、TTS、Emotion、PostgreSQL、Redis、MySQL、MinIO、FRPC | 提供应用、AI 与数据存储服务,通过 FRPC 隧道反向映射至 aliyun 本地回环 |
A.2 FRP 与 ESL 映射
FRPS proxyBindAddr="127.0.0.1",因此下表端口只供 aliyun 本机 Nginx 和 callflow-esl 使用:
aliyun 回环端口 | 后端 |
|---|---|
12000 | MySQL |
12001 | Redis |
12002 | MinIO API |
12003 | MinIO Console |
12004 | PostgreSQL |
12008 | TTS(WSL 29080) |
12009 | Emotion(WSL 29090) |
12010 | ASR(WSL 20096) |
12011 | callflow API(WSL 29913) |
12012 | callout API(WSL 29920) |
12013 | callai-server(WSL 29930,NLU / Chat / Conversation / Script) |
12005、12006、12007 当前未分配:本地推理后端不再经 FRP 暴露,三个 Web 前端由 aliyun 本机 Nginx 静态托管而非隧道回源。隧道清单以仓库内 deploy/wsl-docker/frpc/frpc.toml 为准, 使用说明见 WSL 计算集群部署。
12015/TCP 是 aliyun 上 FreeSWITCH inbound ESL,不属于 FRP。它应用 callflow-esl-inbound ACL,只允许同机回环和明确的 Debian 出口地址;不得按 12000-12013 的回环代理方式理解。
A.3 公网信令与媒体
| 能力 | 当前地址 | 路径 |
|---|---|---|
| callflow Web/API | https://callflow.wangijun.com | Nginx 本地静态托管 SPA;/api/ → 回环 12011,/api/auth → 12012(统一登录),/esl-api/ → 本机 9912 |
| callout Web/API | https://callout.wangijun.com | Nginx 本地静态托管 SPA;/api/ → 回环 12012 |
| callai Web/API | https://callai.wangijun.com | Nginx 本地静态托管 SPA;/api/ → 回环 12013,/api/auth → 12012(统一登录) |
| 平台文档站 | https://ai-voice.wangijun.com | Nginx 本地静态托管 ai-voice-docs 构建产物 |
| 浏览器 SIP WSS | wss://fs.wangijun.com | Nginx TLS → 本机 FreeSWITCH 私网 7443/WSS |
| ASR WSS | wss://asr.wangijun.com/audio | Nginx → 回环 FRP 12010,仅需跨边界的调用者使用 |
| TTS 媒体 | https://tts.wangijun.com | Nginx → 回环 FRP 12008 |
| 情绪音频 | https://emotion.wangijun.com/emotion-audio | Nginx / 文件映射与 Emotion 输出目录对齐 |
| FreeSWITCH 录音 | https://recordings.wangijun.com | aliyun Nginx 本地只读 alias |
| FreeSWITCH RTP | 123.57.205.60:30000-30199/UDP | 公网直达 aliyun,不经 FRP |
| TURN control | 123.57.205.60:3478/TCP+UDP | 公网直达 coturn |
| TURN relay | 123.57.205.60:31000-31040/UDP | 公网直达 coturn,与 RTP 范围分离 |
vars.xml 当前用 172.21.38.245 绑定 SIP/RTP,用 123.57.205.60 作为 external_sip_ip / external_rtp_ip。Nginx 到 FreeSWITCH WSS 仍保持加密上游。FRP 明确不 承载 SIP WSS、FreeSWITCH RTP、TURN control、TURN relay 或录音文件。
A.4 当前拓扑验收重点
aliyun上12000-12013仅监听127.0.0.1,公网无法直连。12015/TCP的 ACL 只包含明确的 callflow-esl / callout 来源。- WSS 注册的 SIP realm 为
fs.wangijun.com,上游仍为7443/WSS。 - 5G 浏览器选择
123.57.205.60:310xxrelay,DTLS 完成且 RTP 双向增长。 - FreeSWITCH 的 RTP 落在
30000-30199,TURN relay 落在31000-31040。 - ASR、TTS、API 经 FRP 断开时故障可见,但不影响 SIP/RTP/TURN 的边缘端口归属。
- 录音从
aliyun本地 alias 读取,TTS 与情绪媒体分别按各自公开 URL 回源。