diff --git a/AGENTS.md b/AGENTS.md index 2892348..03f20ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,10 +80,12 @@ - P01–P08 及 K01–K16 已完成**项目内隔离 Mock** 核验;本轮把分散的人类可读契约、文档与第三方对接合为唯一当前规范,不重审已确认规则。若未来另获启动开发/审查子 Agent 授权,必须按使用者指定的 `gpt-5.6-luna`、`max` 思考和 `fast: true` 逐项核验并显式配置,不静默换模型、降档或关闭 fast。 - 唯一现行 SaaS↔Dispatcher 业务规范是 [`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md);当前项目内 Schema、拓扑、正反例及来源/hash 在 [`contracts/local/`](contracts/local/);内部 Agent RPC 在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。 - 已由当前合同来源清单固定哈希的历史提案与计划保留**原字节**于 [`docs/archive/sources/`](docs/archive/sources/README.md),使用者原有未提交的两份旧对接文档也按原字节归档;旧上游 v1 在 [`docs/archive/upstream/`](docs/archive/upstream/README.md) 可离线校验,但不嵌入运行合同。旧 F/W 工作包、旧 MQ-only 合同及归档不作为当前运行入口。固定 MQ `v1`、HTTP `/internal/v1/dispatcher/...` 和业务 revision 是现行通信规则,不是自有实现代次;不得为历史路径新建兼容或回退。 -- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需完整非生产证据/正式获批指令的 `nonprod-real` 为彼此独立的入口。2026-10-05 正式 SaaS Mock 已驱动真实 Agent/Asterisk 发出五通 SIP INVITE,均由线路返回 480,已证实终结并提交结果但均未接通、未见 LLM 应答;数企→15003164745 受当日 3/3 门禁限制尚未在修复版完成试拨,不能声称六组通过。2026-10-04 获批的同一数企/号码两次单次试拨操作均未观测到 SIP 包或最终结果,第二次已有 Dispatcher 派发回执而 Agent 错误根因未知。第二次派发回执经使用者授权后已私密持久化并确认 MQ,最终结果仍缺失;版本 `d2c4a99` 已通过来源/哈希、非呼出时段不拨号抓包预检与版本级只读主机核验并安装;SaaS、Dispatcher、Agent 在测试机已启动,但没有投递新呼叫,Asterisk 仍为零活动通话。SaaS 测试服务已从获批的新私有快照经 HTTPS 提供租户额度 2/revision 2;2026-10-04 旧事件 `call-669f8829-a111-476d-88c7-3de604bf6dc6` 经使用者单独确认,仅在原始 SQLite 备份、只读通道/抓包核实及操作者证据先私密落盘后,人工标记 Dispatcher inbox 为 `finished` 并释放占用(现为 0),Agent 原 `unknown` 幂等日记、派发回执及原始证据均保留;没有 Agent 签发终结事实或最终结果,不能据此声称真实呼叫成功。不得自动重拨、清理未知执行或以编译/本机 Mock 通过宣称真实通话可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,虽已观察到 SIP 480,但尚未证实线路可接通或完成生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。 +- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需主机核验/正式获批指令的 `nonprod-real` 为彼此独立的入口。2026-10-05 正式 SaaS Mock 已驱动真实 Agent/Asterisk 发出五通 SIP INVITE,均由线路返回 480,已证实终结并提交结果但均未接通、未见 LLM 应答;数企→15003164745 受当日 3/3 门禁限制尚未在修复版完成试拨,不能声称六组通过。2026-10-04 获批的同一数企/号码两次单次试拨操作均未观测到 SIP 包或最终结果,第二次已有 Dispatcher 派发回执而 Agent 错误根因未知。第二次派发回执经使用者授权后已私密持久化并确认 MQ,最终结果仍缺失;版本 `d2c4a99` 已通过来源/哈希、非呼出时段不拨号抓包预检与版本级只读主机核验并安装;SaaS、Dispatcher、Agent 在测试机已启动,但没有投递新呼叫,Asterisk 仍为零活动通话。SaaS 测试服务已从获批的新私有快照经 HTTPS 提供租户额度 2/revision 2;2026-10-04 旧事件 `call-669f8829-a111-476d-88c7-3de604bf6dc6` 经使用者单独确认,仅在原始 SQLite 备份、只读通道/抓包核实及操作者证据先私密落盘后,人工标记 Dispatcher inbox 为 `finished` 并释放占用(现为 0),Agent 原 `unknown` 幂等日记、派发回执及原始证据均保留;没有 Agent 签发终结事实或最终结果,不能据此声称真实呼叫成功。不得自动重拨、清理未知执行或以编译/本机 Mock 通过宣称真实通话可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,虽已观察到 SIP 480,但尚未证实线路可接通或完成生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。 - 使用者批准独立的测试专用 `deploys/test/saas-mock/` 仅模拟缺失的 SaaS:从 0600 的静态快照提供五类正式 HTTP 配置,在专用 RabbitMQ vhost 中由模拟 SaaS 预建现行拓扑;不在 Dispatcher 内注入快照;默认不发布外呼消息,只有受限脚本已为同一 `event_id`/线路/原始号码启动抓包后,才可显式单次 MQ 投递;不替代 Agent/Asterisk/AI/OSS。测试用正式入口必须同时具备只读配置、专用 MQ、真实 Agent/Asterisk/AI/录音/OSS、逐通确认和拨号前活跃抓包证据,缺一项不得试拨。此模拟不构成真实 SaaS 签收。 - 开发按 TDD 分批,小步提交;不得覆盖使用者现存修改/未跟踪文件,不自动清理、迁移或覆盖任何现存 SQLite、spool、outbox 和 Agent 恢复文件。旧 `.executions` 及恢复根目录中旧 `.uploads`、`.upload-locks`、逐执行 `state.json` 的发现须只读失败关闭,现存未交付事实由使用者确认处置。真实云账号、EIP、线路、拨号、生产部署和共享数据操作分别需要明确授权。 +- 2026-10-08 使用者批准独立测试工具 [`cmd/sip-call`](cmd/sip-call/README.md):单独编译,以 `sip-call call --sip <线路.env> <原始号码>` 自动加载固定 ENV、临时配置并核对原生 Asterisk,仅拨一通,不接 SaaS/MQ/AI/OSS,不重拨、不换线;业务 Agent/其他 ARI 应用须停止,活动通话或旧测试配置阻断执行。仅 `--debug/-D` 才使用 tcpdump/tshark 收集本通证据;不带参数只报告原生通道事实,不编造 SIP/媒体/抓包事实。`sip-go-agent agent` 同样增加 `--debug/-D`,默认不依赖外部 tcpdump/抓包凭证;调试复用现有抓证脚本并在拨号前严格核对活跃凭证,不能静默降级。HEP 真实 SIP 响应、业务授权、时段、额度及未知执行保留规则不变。开发核验已完成;2026-10-08 经使用者另行确认,独立工具及按当前表生成的三份 0600 ENV 已部署到登记测试机 `rogee` 用户的 `~/sip-call/`,未更新业务 Agent/Dispatcher、未加载或改动既有 Asterisk 线路、未拨号或调用 AI。默认模式不依赖抓包工具;主机未安装 tshark,`-D` 抓证尚未就绪,不能静默降级。部署来源、文件 hash 和只读主机事实见 [`docs/evidence/sip-call-test-deployment-20261008.md`](docs/evidence/sip-call-test-deployment-20261008.md)。独立工具不属于生产发布制品,也不授权真实试拨。 + ## SaaS、Dispatcher 与 Agent 的现行边界 - SaaS→Dispatcher 的**五类只读配置**为 `GET /internal/v1/dispatcher/sip`、`/task/:task_id`、`/tasks`、`/tenant/:tenant_id/quota`、`/ai-providers`;路径前缀固定,均须校验 Dispatcher UUID/资源归属、数字 `tenant_id`、完整快照、来源、有效授权和版本。配置读失败、过期、矛盾或不确定时关新准入;没有旧 MQ 配置回退、通用业务 HTTP、ETag 兜底或偷偷启用旧执行字段。已接纳任务持久绑定原快照。 @@ -108,7 +110,7 @@ | 百应 | `160.202.254.79:5060` | `KQ91526` | `mka755108` | -- 全线路的原始被叫号码仅由校验归属及任务后的 SaaS `call.execute.payload.callee` 确定;不在 Dispatcher、SaaS Mock 或抓证脚本设置固定号码/日期特判,不向任务快照增设号码列表。只接受 1–32 位 ASCII 数字;格式校验不等于真实拨号授权。不再另设本地固定的 `09:00`–`20:00` 窗口或每线路每号码每日 3 次上限;任务、线路时段和额度以 SaaS 配置快照校验为准,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排,并由专用主机脚本在拨号前启用抓包和 PJSIP logger、签发与该通 `event_id`/trunk/原始号码绑定的短时有效活跃抓包凭证;Agent 拒绝缺失/失效/不匹配的凭证。SaaS Mock 投递和本地时段测试不构成真实拨号授权。 +- 业务全线路的原始被叫号码仅由校验归属及任务后的 SaaS `call.execute.payload.callee` 确定;不在 Dispatcher、SaaS Mock 或抓证脚本设置固定号码/日期特判,不向任务快照增设号码列表。只接受 1–32 位 ASCII 数字;格式校验不等于真实拨号授权。不再另设本地固定的 `09:00`–`20:00` 窗口或每线路每号码每日 3 次上限;任务、线路时段和额度以 SaaS 配置快照校验为准,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排;仅在 Agent 指定 `--debug/-D` 时,由专用主机脚本在拨号前启用抓包和 PJSIP logger、签发与该通 `event_id`/trunk/原始号码绑定的短时有效活跃抓包凭证,调试 Agent 拒绝缺失/失效/不匹配的凭证。默认 Agent 不依赖外部抓包或凭证;独立 sip-call 的号码与线路仅来自本次显式命令和 ENV,不冒充业务任务授权。SaaS Mock 投递和本地时段测试不构成真实拨号授权。 - 任务按周一至周日多个时段与指定排除日期配置,线路只有每周允许时段(**没有线路排除日期**),Asia/Shanghai 左闭右开、跨日拆分;缺失或不确定 fail-closed,不自动重拨。由 Dispatcher 在持久接纳与实际发出指令前判定,并取任务/获批 AI 较小通话时限;Agent 仅校验会话和签发期限,不重算外呼策略。本地 SaaS 快照策略测试与主机抓证/逐次授权须分别报告。 - 当前修复仅在本地核验,未更新测试机上的旧多主叫 Mock 快照,也未部署或授权新呼叫。`BD` 等主叫原值不得清洗或当作 Digest 用户名;业务原始被叫号码不变,仅被选定数企 trunk 按规则构造 `7089<原号>`(其它线路使用自己的前缀),不重复加前缀。三条 trunk 独立,不能把同地址伪造为备用线路或换线重拨;服务商反馈 PCMA,对应 Asterisk `allow=alaw`,传输/注册/鉴权/并发仍待真实签收。不以 sipgo/diago 另造 Asterisk 替代架构。 @@ -117,6 +119,6 @@ - 项目是独立 Go module,工具链 Go **1.27.1**;普通构建、测试、运行不读取父项目业务模块、数据库、env 或夹具。标准库和成熟官方 SDK 优先,Cobra 显式 `agent`/`dispatcher`,单制品分角色/权限/目录。禁止自行重写 SIP/ARI、RTP/RTCP/G.711、WebSocket、AMQP、SQLite 驱动、OSS 签名及 SDK 已覆盖的 AI 协议;核验现有依赖能力后再新增库。生产原生 Asterisk 仍由独立 Cell 的 systemd 统一管理,不声称当前 Mock 已完成真实媒体或 1000 路容量验收。 - 生产 ECS 优先 Debian 13(Trixie)minimal;只有阿里云北京无可用镜像时允许 Ubuntu 24.04 LTS。Debian 12 仅供明确标记的非生产测试:必须在 Debian 12 原生构建 Asterisk,不得部署 Debian 13 编译的制品,也不得据此批准生产发布。Asterisk 直接安装在承载 ECS 主机,以 `rogee` 的 `systemd --user` 服务管理,核对 `enabled+active`;生产环境还须启用 `loginctl enable-linger rogee` 并验证重启后持续运行。非生产若未启用 lingering,必须标记重启自启动未验收。不得以前台进程或容器入口代替。 - 开发、Mock、mixed、real 的**非生产主机**部署与诊断步骤默认强制,不因时间/旧环境/调用方参数跳过或静默降级;显式关闭即失败。每次新主机/版本/Cell 至少留存脱敏 ECS/EIP/网络只读核验、Debian/架构/磁盘/权限、`rogee` SSH 与加固、发布包/依赖 SHA-256、Asterisk/systemd `enabled+active`、ARI/PJSIP endpoint/contact、媒体 profile/端口及运行版本。 -- 非生产 mixed/real 呼叫必须通过 SaaS 下发的任务、线路时段与额度校验及逐次授权;**拨号前**启动受限 SIP/RTP 抓包和 Asterisk PJSIP logger,结束后采集 SIP 响应/INVITE–BYE 时间线、SDP codec/媒体地址端口、RTP 包/字节、录音与 ASR/LLM/TTS 事实及 SHA-256。失败通话也保存状态和抓包;tcpdump/CAP_NET_RAW、PJSIP logger 或 ARI/PJSIP 状态任一不可用须失败关闭。原始抓包/日志/录音只写受限证据目录,聊天、提交与长期证据只存脱敏摘要/计数/状态码/hash,不含完整用户音频/对话或凭据。统一入口见 [`deploys/test/nonprod-call-evidence.sh`](deploys/test/nonprod-call-evidence.sh);本地 `make check` 与 `make release-check-local` **不能代签主机诊断或生产门禁**。 +- 非生产 mixed/real 呼叫必须通过 SaaS 下发的任务、线路时段与额度校验及逐次授权;ARI/PJSIP 状态必须可核验。仅指定 Agent `--debug/-D` 时,**拨号前**启动受限 SIP/RTP 抓包和 Asterisk PJSIP logger,结束后采集 SIP 响应/INVITE–BYE 时间线、SDP codec/媒体地址端口、RTP 包/字节、录音与 ASR/LLM/TTS 事实及 SHA-256,失败通话也保存状态和抓包;此调试模式下 tcpdump/CAP_NET_RAW、PJSIP logger 或证据凭证任一不可用须失败关闭。默认 Agent 不依赖上述外部抓包工具,不能将未抓包详情作为抓包证据。独立 sip-call 不测试 AI/录音链路,其调试 pcap 只覆盖指定服务端 IP,不能据缺失 RTP 断言无媒体。原始抓包/日志/录音只写受限证据目录,聊天、提交与长期证据只存脱敏摘要/计数/状态码/hash,不含完整用户音频/对话或凭据。统一入口见 [`deploys/test/nonprod-call-evidence.sh`](deploys/test/nonprod-call-evidence.sh);本地 `make check` 与 `make release-check-local` **不能代签主机诊断或生产门禁**。 - 当前完成前至少检查格式、当前合同和 Proto 来源/hash、`go vet ./...`、`go test -race ./...`、构建、确实运行的隔离 RabbitMQ/HTTPS/双向 TLS 端到端测试、业务单元覆盖率 ≥65% 及 A01–A12/K01–K16 对照。真实 SaaS/management/OSS/AI/Asterisk/ECS、第二节点/Cell/租户、多 D 额度、容量/N+1及生产切换必须另有事实与授权,任何本机 Mock 通过不得写成其签收。 - 不自动提交/暂存/清理使用者在父项目或本项目的无关修改;并行开发仍须有可追溯 Git/合同基线、一 lane 一工作区/测试资源、无交叠写集合、合并后回归。本目标明确禁用子 Agent,不能以模型、fast 环境或外部服务不可用阻塞本地 Mock 目标。问题根因不明时补可观测性并诚实报告,不能用静默兜底伪装修复。 diff --git a/Makefile b/Makefile index a6537c5..891b547 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: fmt test test-race coverage vet build security release release-check-local mq-integration-local proto-lint proto-generate proto-check contract-check acceptance-local check +.PHONY: fmt test test-race coverage vet build build-sip-call security release release-check-local mq-integration-local proto-lint proto-generate proto-check contract-check acceptance-local check GO ?= go BINARY ?= dist/sip-go-agent @@ -26,6 +26,10 @@ build: mkdir -p $(dir $(BINARY)) $(GO) build -trimpath -buildvcs=false -o $(BINARY) ./cmd/sip-go-agent +build-sip-call: + mkdir -p dist + $(GO) build -trimpath -buildvcs=false -o dist/sip-call ./cmd/sip-call + release: ./scripts/build-release.sh $(RELEASE_DIR) diff --git a/cmd/sip-call/README.md b/cmd/sip-call/README.md new file mode 100644 index 0000000..8b09fea --- /dev/null +++ b/cmd/sip-call/README.md @@ -0,0 +1,69 @@ +# 单通外呼线路测试 + +独立程序 `sip-call`,只复用项目包,不进入 Agent/Dispatcher 业务流程。不接 SaaS、MQ、AI、OSS,不录音,不排队、不重拨、不换线。 + +## 使用 + +```sh +make build-sip-call +cp cmd/sip-call/sip.env.example sip-xx.env +chmod 600 sip-xx.env +# 填写服务商确认的线路参数及本机 Asterisk 路径后执行: +./dist/sip-call call --sip sip-xx.env 18601010101 +# 需要本通抓包与 SIP/RTP 证据时: +./dist/sip-call call --sip sip-xx.env -D 18601010101 +``` + +`--debug` 与 `-D` 等价。默认模式不调用或检查 tcpdump/tshark;调试模式要求两者可执行、指定接口可抓包且权限充分。调试抓包未就绪或在 Dial 前已退出就不拨号。参数后的号码是原始号码,仅添加该文件声明的前缀一次;不要自己先加线路前缀。 + +**命令会真实拨号。每次执行都须另获线路和号码的明确授权;本文示例不是授权。** 本次代码交付只做本地测试,未部署、未试拨。 + +## 运行条件 + +- 在已有原生 Asterisk 的主机运行;已有 `asterisk.conf`、0600 的 `pjsip.conf`、原生 PJSIP/ARI 模块及私有 `ari-secret`。 +- 使用现有本地 ARI 用户 `go-sip-agent` 和已配置的 loopback HTTP 地址;密码只从 `ASTERISK_CONFIG_DIR/ari-secret` 在内存读取,不复制到 ENV 或结果。 +- 已有静态 `go-sip-udp` UDP transport,绑定 `0.0.0.0:5060`;工具不更改静态 transport,不启动/重启 Asterisk。 +- 不存在活动通话,且业务调用方、Agent/其他 ARI 应用已停止。工具拒绝接管业务应用;采用独立 `sip-call` ARI 应用及 `siptest-` endpoint。 +- 只支持 IPv4、UDP、IP 鉴权、无需 REGISTER、PCMA(alaw)。其他方式明确拒绝,不猜测配置。 +- ENV 为 0600 普通文件;结果目录为 0700。所有配置项见 [`sip.env.example`](sip.env.example);不读取 shell 环境覆盖线路,也不执行 `source`。 + +## 自动配置与退出 + +取得该 Asterisk 配置目录的独占测试锁,确认空闲后保存原 `pjsip.conf` 到本次私有目录的 `pjsip.conf.before`。临时增加独立 include/endpoint/AOR,reload 后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。 + +收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后恢复原 `pjsip.conf` 原字节、删除本次专用配置、reload 并确认 endpoint 已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。 + +旧 `sip-call-managed.conf`/include、并发配置修改或未知通道状态均报错。无法确认结束时保留测试配置和原配置副本供人工排查,**不要直接重跑或自行清理**。恢复、抓包停止或证据解析失败都保留错误并以非零退出,不报告“全部正常”。 + +## 输出与证据 + +每次创建独立私有目录,成功或失败均有 `result.json`:线路、服务地址、主叫、原始/线路被叫、SIP Call-ID、拨号前绑定时间、接通时间、结束确认时间、最终状态及失败原因。 + +开启调试后另有: + +- `capture.pcap` 与 SHA-256:只抓指定 SIP 服务端 IP 的 UDP,避免复制其他主机流量。 +- `tcpdump.log`:本通抓包工具的启动/退出信息。 +- `sip.tsv`:真实 tshark 按本通 Call-ID 解出的 SIP 时间线;最终响应进一步匹配原始 INVITE 的 CSeq 与 Via branch,不能把 BYE 的 200 当作接通。 +- `rtp.tsv`:依据该通 SDP 音频端口识别的 RTP 包及字节;结果明确记录观察范围。 + +SIP 服务端与媒体服务器使用不同 IP 时,这个窄过滤器**不覆盖另一个媒体 IP**;未观察到 RTP 不能据此断言无媒体。裸线路测试不发送合成语音、不验证双向音质,RTP 包存在也不等于语音/AI 正常。 + +调试模式观察到原生接通但没有同一原始 INVITE 的真实 2xx 最终响应时,保留原生接通/结束事实,同时明确报告抓证失败,不给出完整成功结论。 + +未开调试时只报告原生通道事实,不编造 SIP 状态码、媒体事实或抓包证据。原始抓包可能含用户音频,配置副本也须私密保存;不要复制到聊天、普通日志或 Git。 + +## Agent 的调试参数 + +```sh +sip-go-agent agent --mode nonprod-real -D +``` + +普通 Agent 默认不依赖 tcpdump,不校验外部抓包凭证。`-D` 则复用 [`nonprod-call-evidence.sh`](../../deploys/test/nonprod-call-evidence.sh):脚本提前开启抓包与 PJSIP logger,Agent 核对 `AGENT_EVIDENCE_ROOT` 下绑定本通 event/线路/原始号码的活跃凭证。调试条件缺失拒绝拨号;脚本不会因为新增参数而自动启动。 + +此参数仅属于 Agent,Dispatcher 不接受。业务授权、任务/线路时段、额度和 HEP 原生 SIP 响应采集规则未取消;没有 tcpdump 不等于绕过业务门禁。 + +## 本地验证范围 + +单元测试覆盖参数、ENV、一次拨号、无 AI/ExternalMedia/bridge、失效抓包、异常恢复、未知结束和原始 INVITE 关联。额外使用网络禁用的临时 Debian 容器中真实 tcpdump/tshark **离线解析合成 pcap**,可用 `GO_SIP_TSHARK_TEST_IMAGE` 显式启用对应测试。 + +这些测试没有向真实线路发送 SIP,也不能证明已接通、媒体可用或生产可发布。 diff --git a/cmd/sip-call/main.go b/cmd/sip-call/main.go new file mode 100644 index 0000000..8f079b7 --- /dev/null +++ b/cmd/sip-call/main.go @@ -0,0 +1,73 @@ +// sip-call is a standalone, operator-invoked Asterisk line test. It does not +// import the Agent/Dispatcher commands or require a business configuration. +package main + +import ( + "context" + "errors" + "fmt" + "git.ipao.vip/rogee/go-sip/internal/sipcall" + "github.com/spf13/cobra" + "io" + "os" + "os/signal" + "syscall" +) + +type callRunner func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if err := newRootCommand(sipcall.Run).ExecuteContext(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func newRootCommand(run callRunner) *cobra.Command { + var debug bool + var file string + root := &cobra.Command{Use: "sip-call", Short: "单通 Asterisk 外呼线路测试(不接 AI)", SilenceUsage: true, SilenceErrors: true, RunE: func(*cobra.Command, []string) error { + return errors.New("请使用 sip-call call --sip <线路.env> <原始号码>") + }} + root.PersistentFlags().BoolVarP(&debug, "debug", "D", false, "开启本通抓包和 SIP/RTP 证据提取;缺少工具或权限就拒绝拨号") + call := &cobra.Command{Use: "call --sip <线路.env> <原始号码>", Short: "自动临时配置线路,只拨一次,接通后短暂保持并挂断", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + c, err := sipcall.LoadConfig(file) + if err != nil { + return err + } + if _, err = c.Route(args[0]); err != nil { + return err + } + result, runErr := run(cmd.Context(), c, args[0], debug) + if result != nil { + runErr = errors.Join(runErr, printReport(cmd.OutOrStdout(), result)) + } + return runErr + }} + call.Flags().StringVar(&file, "sip", "", "线路 ENV 文件(0600;不执行 shell)") + if err := call.MarkFlagRequired("sip"); err != nil { + panic(err) + } + root.AddCommand(call) + return root +} +func printReport(out io.Writer, r *sipcall.Report) error { + status := map[string]string{"connected": "已接通并确认结束", "not_connected": "未接通", "not_dialed": "未拨号", "unknown": "状态不明,禁止自动重拨", "failed": "失败(详情见结果文件)"}[r.Status] + if status == "" { + status = "状态不明" + } + _, err := fmt.Fprintf(out, "结论:%s\n线路:%s (%s)\n主叫:%s\n原始被叫:%s\n线路被叫:%s\n详情:%s\n", status, r.Line, r.Server, r.CallerID, r.Callee, r.DialedCallee, r.Directory+"/result.json") + if r.Evidence != nil { + code := "未观测到最终响应" + if r.Evidence.FinalINVITECode != 0 { + code = fmt.Sprint(r.Evidence.FinalINVITECode) + } + _, e := fmt.Fprintf(out, "抓包:%s\nSHA-256:%s\nSIP 时间线:%s\n原始 INVITE 最终响应:%s\n媒体观察:%s\n", r.Evidence.PCAP, r.Evidence.PCAPSHA256, r.Evidence.SIPTimeline, code, r.Evidence.RTPStatus) + err = errors.Join(err, e) + } else { + _, e := fmt.Fprintln(out, "抓包:未启用(使用 --debug/-D 开启);没有抓包链路证据或媒体验证。") + err = errors.Join(err, e) + } + return err +} diff --git a/cmd/sip-call/main_test.go b/cmd/sip-call/main_test.go new file mode 100644 index 0000000..c1db7c2 --- /dev/null +++ b/cmd/sip-call/main_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "context" + "errors" + "git.ipao.vip/rogee/go-sip/internal/sipcall" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCallCLIHasNoBusinessOrAIDependencies(t *testing.T) { + for _, args := range [][]string{{"call", "--sip", "line.env", "18601010101"}, {"call", "--sip", "line.env", "-D", "18601010101"}, {"--debug", "call", "--sip", "line.env", "18601010101"}} { + dir := t.TempDir() + env := filepath.Join(dir, "line.env") + data := `SIP_ID=line +SIP_SERVER=192.0.2.1 +SIP_PORT=5060 +SIP_CALLER_ID=BD123 +SIP_PREFIX=7089 +SIP_TRANSPORT=udp +SIP_AUTH=ip +SIP_REGISTER=no +SIP_CODEC=alaw +ASTERISK_CONFIG_DIR=/tmp/ast +ASTERISK_BIN=/usr/local/sbin/asterisk +ASTERISK_LIBRARY_DIR=/usr/local/lib +OUTPUT_DIR=/tmp/results +RING_SECONDS=20 +HOLD_SECONDS=3 +DEBUG_INTERFACE=any +` + if err := os.WriteFile(env, []byte(data), 0600); err != nil { + t.Fatal(err) + } + for i := range args { + if args[i] == "line.env" { + args[i] = env + } + } + calls := 0 + root := newRootCommand(func(ctx context.Context, c sipcall.Config, n string, debug bool) (*sipcall.Report, error) { + calls++ + want := strings.Contains(strings.Join(args, " "), "-D") || args[0] == "--debug" + if debug != want || n != "18601010101" || c.ID != "line" { + t.Fatal("incorrect CLI inputs") + } + return &sipcall.Report{Status: "connected", Directory: "/tmp/result", Callee: n, DialedCallee: c.Prefix + n, Debug: debug}, nil + }) + out := &bytes.Buffer{} + root.SetOut(out) + root.SetErr(out) + root.SetArgs(args) + if err := root.Execute(); err != nil || calls != 1 { + t.Fatalf("%v %v", args, err) + } + if !strings.Contains(out.String(), "result.json") { + t.Fatal("result path missing") + } + } +} + +func TestCallCLIRejectsMissingOrMalformedInputsBeforeHostAccess(t *testing.T) { + for _, args := range [][]string{{}, {"call"}, {"call", "123"}, {"call", "--sip", "missing.env", "+123"}, {"call", "--sip", "missing.env", "123", "456"}, {"agent"}} { + root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) { + t.Fatal("invalid input must never access host") + return nil, nil + }) + root.SetArgs(args) + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + if err := root.Execute(); err == nil { + t.Fatalf("invalid invocation accepted: %v", args) + } + } +} + +func TestCallCLIDoesNotHideRunFailures(t *testing.T) { + errExpected := errors.New("unconfirmed call") + out := &bytes.Buffer{} + err := printReport(out, &sipcall.Report{Status: "unknown", Directory: "/tmp/test", Failure: errExpected.Error()}) + if err != nil || !strings.Contains(out.String(), "状态不明") { + t.Fatal("unknown call must not look connected") + } +} diff --git a/cmd/sip-call/sip.env.example b/cmd/sip-call/sip.env.example new file mode 100644 index 0000000..cd54956 --- /dev/null +++ b/cmd/sip-call/sip.env.example @@ -0,0 +1,25 @@ +# Copy to a private file, chmod 600, then fill your provider-approved values. +# Documentation-only address: this template cannot test a real line as-is. +# Plain KEY=VALUE only: no quotes, shell commands, variables or duplicate keys. +SIP_ID=test-line +SIP_SERVER=192.0.2.10 +SIP_PORT=5060 +SIP_CALLER_ID=YOUR_CALLER_ID +# Empty is valid, but this field must be present. Raw CLI number is unchanged. +SIP_PREFIX= +# Only these supported values are accepted; no REGISTER or Digest credentials. +SIP_TRANSPORT=udp +SIP_AUTH=ip +SIP_REGISTER=no +SIP_CODEC=alaw + +# Existing native Asterisk installation and the user's private ari-secret. +ASTERISK_CONFIG_DIR=/home/rogee/.config/go-sip-asterisk +ASTERISK_BIN=/usr/local/sbin/asterisk +ASTERISK_LIBRARY_DIR=/usr/local/lib +OUTPUT_DIR=/home/rogee/.local/state/sip-call +# Ring: 1-120 seconds. Connected hold: 1-30 seconds. No redial. +RING_SECONDS=20 +HOLD_SECONDS=3 +# Used only with --debug/-D; no tcpdump/tshark needed otherwise. +DEBUG_INTERFACE=any diff --git a/cmd/sip-go-agent/agent_command.go b/cmd/sip-go-agent/agent_command.go index f3e7278..d262e05 100644 --- a/cmd/sip-go-agent/agent_command.go +++ b/cmd/sip-go-agent/agent_command.go @@ -23,6 +23,7 @@ import ( func newAgentCommand() *cobra.Command { var mode string + var debug bool command := &cobra.Command{ Use: "agent", Short: "Run the bound Agent", Args: cobra.NoArgs, SilenceUsage: true, @@ -31,6 +32,10 @@ func newAgentCommand() *cobra.Command { if err != nil { return err } + settings.Debug = debug + if err := settings.ValidateDebug(); err != nil { + return err + } ca, err := readAgentPEM("MTLS_CA_FILE", settings.CAFile) if err != nil { return err @@ -146,6 +151,7 @@ func newAgentCommand() *cobra.Command { return nil }, } + command.Flags().BoolVarP(&debug, "debug", "D", false, "Require per-call evidence from the nonproduction capture script") command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock, SIP-only or explicit nonprod-real mode") return command } diff --git a/cmd/sip-go-agent/agent_real.go b/cmd/sip-go-agent/agent_real.go index 1538fb3..82a640a 100644 --- a/cmd/sip-go-agent/agent_real.go +++ b/cmd/sip-go-agent/agent_real.go @@ -40,7 +40,10 @@ func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, d if value.Kind() == reflect.Pointer && value.IsNil() { return nil, errors.New("real Agent requires a live pinned Dispatcher transport") } - for _, path := range []string{settings.AsteriskConfigDir, settings.AsteriskBin, settings.AsteriskLibraryDir, settings.EvidenceRoot, settings.RecoveryRoot} { + if err := settings.ValidateDebug(); err != nil { + return nil, err + } + for _, path := range []string{settings.AsteriskConfigDir, settings.AsteriskBin, settings.AsteriskLibraryDir, settings.RecoveryRoot} { if !filepath.IsAbs(path) { return nil, errors.New("real Agent paths must be absolute") } @@ -85,7 +88,7 @@ func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, d Journal: &agent.ResultJournal{Root: settings.RecoveryRoot}, } return (&rpc.ApprovedRecordedRealCall{ - Loader: loader, MediaPayloadType: 118, EvidenceRoot: settings.EvidenceRoot, + Loader: loader, MediaPayloadType: 118, EvidenceRoot: settings.EvidenceRoot, Debug: settings.Debug, MaxWAVBytes: 64 << 20, ReportTimeout: 15 * time.Minute, Delivery: delivery, Mirror: mirror, }).Prepare(execution) }, diff --git a/cmd/sip-go-agent/debug_test.go b/cmd/sip-go-agent/debug_test.go new file mode 100644 index 0000000..817f1e1 --- /dev/null +++ b/cmd/sip-go-agent/debug_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestDebugFlagIsExplicitAndAgentOnly(t *testing.T) { + for _, flag := range []string{"--debug", "-D"} { + command := newAgentCommand() + if err := command.ParseFlags([]string{flag}); err != nil { + t.Fatal(err) + } + enabled, err := command.Flags().GetBool("debug") + if err != nil || !enabled { + t.Fatalf("%s: %v %v", flag, enabled, err) + } + if err := newDispatcherCommand().ParseFlags([]string{flag}); err == nil { + t.Fatal("Dispatcher must not silently accept an Agent capture flag") + } + } + command := newAgentCommand() + enabled, err := command.Flags().GetBool("debug") + if err != nil || enabled { + t.Fatal("capture must be disabled by default") + } +} diff --git a/contracts/schema/README.md b/contracts/schema/README.md new file mode 100644 index 0000000..aa238d2 --- /dev/null +++ b/contracts/schema/README.md @@ -0,0 +1,71 @@ +# 按接口拆分的 JSON Schema + +此目录提供五类 HTTP 响应和六类 MQ 消息的独立校验入口,使用 JSON Schema Draft 2020-12。重复且约束相同的结构通过相对 `$ref` 引用 `common.schema.json`,仅单个入口使用的结构保留在该入口的 `$defs` 中。 + +本次仅新增文件,不替换或删除 `contracts/local/` 的原文件,不修改现有程序的校验入口、嵌入资源或来源清单。程序仍使用原有 Schema;此目录尚未接入运行或发布流程,不构成外部 SaaS 签收。 + +## HTTP 响应 + +下列入口校验响应 JSON 正文,不校验请求路径、查询参数或 HTTP 状态码。每个入口仅接受自己的成功响应或公共错误响应。 + +| 接口 | Schema | 现有示例(相对本目录) | +| --- | --- | --- | +| `GET /internal/v1/dispatcher/sip` | [`http-sip.schema.json`](http-sip.schema.json) | [`config-read-sip.json`](../local/examples/config-read-sip.json)、[`config-read-sip-no-revision.json`](../local/examples/config-read-sip-no-revision.json) | +| `GET /internal/v1/dispatcher/task/:task_id` | [`http-task-detail.schema.json`](http-task-detail.schema.json) | [`config-read-task-full.json`](../local/examples/config-read-task-full.json)、[`config-read-task-asr.json`](../local/examples/config-read-task-asr.json) | +| `GET /internal/v1/dispatcher/tasks` | [`http-task-list.schema.json`](http-task-list.schema.json) | [`task-discovery-page.json`](../local/examples/task-discovery-page.json)、[`task-discovery-end.json`](../local/examples/task-discovery-end.json) | +| `GET /internal/v1/dispatcher/tenant/:tenant_id/quota` | [`http-tenant-quota.schema.json`](http-tenant-quota.schema.json) | [`config-read-quota.json`](../local/examples/config-read-quota.json) | +| `GET /internal/v1/dispatcher/ai-providers` | [`http-ai-providers.schema.json`](http-ai-providers.schema.json) | [`config-read-providers.json`](../local/examples/config-read-providers.json)、[`config-read-bailian-providers.json`](../local/examples/config-read-bailian-providers.json) | + +公共错误响应示例:[`config-read-error.json`](../local/examples/config-read-error.json)。 + +## MQ 消息 + +按消息类型和方向选择入口。`task.control` 的请求和回执共用事件名称,`call.execute` 的请求和派发回执也共用事件名称,不能仅依据 `event_type` 选择 Schema。 + +| 消息 | 方向 | Schema | 现有示例(相对本目录) | +| --- | --- | --- | --- | +| `sip.config` | SaaS → Dispatcher | [`mq-sip-config.schema.json`](mq-sip-config.schema.json) | [`mq-sip-change.json`](../local/examples/mq-sip-change.json) | +| `task.control` 请求 | SaaS → Dispatcher | [`mq-task-control-request.schema.json`](mq-task-control-request.schema.json) | [`mq-control.json`](../local/examples/mq-control.json)、[`mq-control-start.json`](../local/examples/mq-control-start.json)、[`mq-control-edit.json`](../local/examples/mq-control-edit.json) | +| `task.control` 回执 | Dispatcher → SaaS | [`mq-task-control-receipt.schema.json`](mq-task-control-receipt.schema.json) | [`mq-control-ack.json`](../local/examples/mq-control-ack.json) | +| `call.execute` 请求 | SaaS → Dispatcher | [`mq-call-execute-request.schema.json`](mq-call-execute-request.schema.json) | [`mq-execute.json`](../local/examples/mq-execute.json) | +| `call.execute` 派发/拒绝回执 | Dispatcher → SaaS | [`mq-call-execute-receipt.schema.json`](mq-call-execute-receipt.schema.json) | [`mq-execute-ack.json`](../local/examples/mq-execute-ack.json)、[`mq-execute-rejected.json`](../local/examples/mq-execute-rejected.json) | +| `call.execute.result` 最终结果 | Dispatcher → SaaS | [`mq-call-execute-result.schema.json`](mq-call-execute-result.schema.json) | [`mq-result-uploaded.json`](../local/examples/mq-result-uploaded.json)、[`mq-result-no-recording.json`](../local/examples/mq-result-no-recording.json) | + +## 公共定义与引用 + +[`common.schema.json`](common.schema.json) 仅包含 `$defs`,不是独立消息的校验入口: + +- `dispatcher_id`、`tenant_id`、`task_id`:公共身份字段约束。 +- `event_id`、`issued_at`:MQ 事件身份和时间约束。 +- `task_status`:任务详情与任务列表共用的任务状态。 +- `error`:公共 HTTP 错误响应。 +- `weekly_windows`、`windows`:任务和线路共用的每周时段。 + +引用示例: + +```json +{ + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + } +} +``` + +任务时段和线路时段仍分别定义:任务保留有效起止时间和排除日期,线路不包含排除日期。任务详情内的 AI、SIP 线路和最终结果中的录音结构不增加跨入口抽象。 + +## 使用方式 + +1. 根据上表选择一个入口文件,而不是把所有入口合并为一个 `oneOf`。 +2. 向校验器注册所用入口及其公共依赖。各文件的 `$id` 是稳定的资源标识,不是要求联网访问的下载地址;注册时使用文件内的 `$id`;若按本地文件 URI 加载,须将这些 `$id` 映射到对应的本地资源。 +3. 离线解析 `./common.schema.json`。不得静默忽略缺失引用或改为联网下载;缺少依赖必须报错。移动或分发文件时保留相对目录关系。 +4. 开启 `format` 校验,使 `uuid`、`date-time`、`date` 的检查与现有 Go 校验入口一致。现有 `github.com/santhosh-tekuri/jsonschema/v6` 支持 `AddResource`、`Compile` 和 `AssertFormat`,无需新增依赖。 +5. 对响应或消息的 JSON 正文执行校验。资源归属、任务授权、时段关系、线路可用性等业务检查仍由程序执行,Schema 通过不代表业务获准。 + +## 来源及维护边界 + +- 五个配置响应(含公共错误、身份和时段定义)的来源:[`../local/config-read.schema.json`](../local/config-read.schema.json);任务列表来源:[`../local/task-discovery.schema.json`](../local/task-discovery.schema.json)。 +- 六个 MQ 入口的来源:[`../local/mq.schema.json`](../local/mq.schema.json)。 +- 拆分仅改变定义位置和引用方式,不增加、删除或放宽原有字段约束。任务列表对 `schema_version`、`control_seq` 的现有忽略规则也保持不变。 +- HTTP 入口保留原有公共错误分支;MQ 入口严格区分原有消息分支。MQ 回执字段按原 Schema 保留,不自行补充字段。 +- 此目录不引用旧目录中的 Schema;旧目录只作为拆分来源和现有示例位置。独立分发时仅需所用入口及 `common.schema.json`。 +- 原文件、程序和清单仍保持原样。未来如需接入此目录或改变字段,必须另行确认范围,并同步核验新旧定义,不能假定本次已完成切换。 diff --git a/contracts/schema/common.schema.json b/contracts/schema/common.schema.json new file mode 100644 index 0000000..dad7790 --- /dev/null +++ b/contracts/schema/common.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/common.schema.json", + "title": "Shared HTTP and MQ definitions; not a standalone message schema", + "$defs": { + "dispatcher_id": { + "type": "string", + "format": "uuid" + }, + "tenant_id": { + "type": "integer", + "minimum": 1 + }, + "task_id": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,128}$" + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "error" + ], + "properties": { + "resource": { + "const": "error" + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "weekly_windows": { + "type": "object", + "additionalProperties": false, + "required": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], + "properties": { + "monday": { + "$ref": "#/$defs/windows" + }, + "tuesday": { + "$ref": "#/$defs/windows" + }, + "wednesday": { + "$ref": "#/$defs/windows" + }, + "thursday": { + "$ref": "#/$defs/windows" + }, + "friday": { + "$ref": "#/$defs/windows" + }, + "saturday": { + "$ref": "#/$defs/windows" + }, + "sunday": { + "$ref": "#/$defs/windows" + } + } + }, + "windows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "start", + "end" + ], + "properties": { + "start": { + "type": "string", + "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + }, + "end": { + "type": "string", + "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$" + } + } + } + }, + "event_id": { + "type": "string", + "minLength": 1 + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "task_status": { + "enum": [ + "running", + "paused", + "stopped" + ] + } + } +} diff --git a/contracts/schema/http-ai-providers.schema.json b/contracts/schema/http-ai-providers.schema.json new file mode 100644 index 0000000..61c90f9 --- /dev/null +++ b/contracts/schema/http-ai-providers.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/http-ai-providers.schema.json", + "title": "GET /internal/v1/dispatcher/ai-providers response; external SaaS compatibility unverified", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "dispatcher_id", + "providers" + ], + "properties": { + "resource": { + "const": "ai_providers" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/$defs/provider" + } + } + } + }, + { + "$ref": "./common.schema.json#/$defs/error" + } + ], + "$defs": { + "provider": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_id", + "provider_code", + "api_key" + ], + "properties": { + "provider_id": { + "type": "string", + "minLength": 1 + }, + "provider_code": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "api_endpoint": { + "type": "string" + }, + "ws_endpoint": { + "type": "string" + }, + "api_key": { + "type": "string", + "minLength": 1, + "$comment": "Connection credential; never log its content." + }, + "extra_config": { + "type": "object" + } + }, + "anyOf": [ + { + "required": [ + "api_endpoint" + ], + "properties": { + "api_endpoint": { + "minLength": 1 + } + } + }, + { + "required": [ + "ws_endpoint" + ], + "properties": { + "ws_endpoint": { + "minLength": 1 + } + } + } + ] + } + } +} diff --git a/contracts/schema/http-sip.schema.json b/contracts/schema/http-sip.schema.json new file mode 100644 index 0000000..fe9299f --- /dev/null +++ b/contracts/schema/http-sip.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/http-sip.schema.json", + "title": "GET /internal/v1/dispatcher/sip response; external SaaS compatibility unverified", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "dispatcher_id", + "trunks" + ], + "properties": { + "resource": { + "const": "sip_config" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "trunks": { + "type": "array", + "items": { + "$ref": "#/$defs/trunk" + } + } + } + }, + { + "$ref": "./common.schema.json#/$defs/error" + } + ], + "$defs": { + "trunk": { + "type": "object", + "additionalProperties": false, + "required": [ + "trunk_id", + "provider_id", + "codec", + "dial_prefix", + "enabled", + "server_host", + "server_port", + "transport", + "auth_mode", + "registration_required", + "max_concurrent_calls", + "caller_id", + "schedule" + ], + "properties": { + "trunk_id": { + "type": "string", + "minLength": 1 + }, + "provider_id": { + "type": "string", + "minLength": 1 + }, + "codec": { + "const": "PCMA" + }, + "dial_prefix": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "server_host": { + "type": "string", + "minLength": 1 + }, + "server_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "transport": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:[uU][dD][pP]|[tT][cC][pP]|[tT][lL][sS])$" + }, + "auth_mode": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:[iI][pP]|[dD][iI][gG][eE][sS][tT]|[nN][oO][nN][eE])$" + }, + "registration_required": { + "type": [ + "boolean", + "null" + ] + }, + "max_concurrent_calls": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "caller_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$" + }, + "schedule": { + "$ref": "#/$defs/weekly_schedule" + } + } + }, + "weekly_schedule": { + "type": "object", + "additionalProperties": false, + "required": [ + "time_zone", + "weekly_windows" + ], + "properties": { + "time_zone": { + "const": "Asia/Shanghai" + }, + "weekly_windows": { + "$ref": "./common.schema.json#/$defs/weekly_windows" + } + } + } + } +} diff --git a/contracts/schema/http-task-detail.schema.json b/contracts/schema/http-task-detail.schema.json new file mode 100644 index 0000000..b438569 --- /dev/null +++ b/contracts/schema/http-task-detail.schema.json @@ -0,0 +1,457 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/http-task-detail.schema.json", + "title": "GET /internal/v1/dispatcher/task/:task_id response; external SaaS compatibility unverified", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "dispatcher_id", + "tenant_id", + "task_id", + "task_revision", + "status", + "max_concurrent_calls", + "ring_timeout_ms", + "max_call_duration_ms", + "route_policy_id", + "allowed_trunk_ids", + "schedule", + "agent" + ], + "properties": { + "resource": { + "const": "task_config" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "task_id": { + "$ref": "./common.schema.json#/$defs/task_id" + }, + "task_revision": { + "type": "integer", + "minimum": 1 + }, + "status": { + "$ref": "./common.schema.json#/$defs/task_status" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "max_concurrent_calls": { + "type": "integer", + "minimum": 1 + }, + "ring_timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "max_call_duration_ms": { + "type": "integer", + "minimum": 1 + }, + "route_policy_id": { + "type": "string", + "minLength": 1 + }, + "allowed_trunk_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "schedule": { + "$ref": "#/$defs/task_schedule" + }, + "agent": { + "$ref": "#/$defs/agent" + } + } + }, + { + "$ref": "./common.schema.json#/$defs/error" + } + ], + "$defs": { + "task_schedule": { + "type": "object", + "additionalProperties": false, + "required": [ + "time_zone", + "starts_at", + "ends_at", + "weekly_windows", + "excluded_dates" + ], + "properties": { + "time_zone": { + "const": "Asia/Shanghai" + }, + "starts_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "ends_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "weekly_windows": { + "$ref": "./common.schema.json#/$defs/weekly_windows" + }, + "excluded_dates": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "format": "date" + } + } + } + }, + "agent": { + "type": "object", + "additionalProperties": false, + "required": [ + "immutable", + "mode", + "asr" + ], + "properties": { + "immutable": { + "const": true + }, + "mode": { + "enum": [ + "asr_only", + "full_ai" + ] + }, + "asr": { + "$ref": "#/$defs/asr" + }, + "llm": { + "$ref": "#/$defs/llm" + }, + "tts": { + "$ref": "#/$defs/tts" + }, + "prompt": { + "$ref": "#/$defs/prompt" + }, + "conversation": { + "$ref": "#/$defs/conversation" + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "full_ai" + } + } + }, + "then": { + "required": [ + "llm", + "tts", + "prompt", + "conversation" + ] + } + }, + { + "if": { + "properties": { + "mode": { + "const": "asr_only" + } + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "llm" + ] + }, + { + "required": [ + "tts" + ] + }, + { + "required": [ + "prompt" + ] + } + ] + } + } + } + ] + }, + "asr": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_id", + "language", + "input" + ], + "properties": { + "provider_id": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "interim": { + "type": "boolean" + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "input": { + "$ref": "#/$defs/audio_input" + } + } + }, + "audio_input": { + "type": "object", + "additionalProperties": false, + "required": [ + "encoding", + "sample_rate_hz", + "channels", + "sample_width_bytes" + ], + "properties": { + "encoding": { + "const": "pcm_s16le" + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000 + }, + "channels": { + "const": 1 + }, + "sample_width_bytes": { + "const": 2 + } + } + }, + "llm": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_id", + "model" + ], + "properties": { + "provider_id": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + } + } + }, + "tts": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_id", + "model", + "voice", + "language_type", + "format" + ], + "properties": { + "provider_id": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + }, + "language_type": { + "type": "string", + "minLength": 1 + }, + "speed": { + "type": "number", + "minimum": 0.25, + "maximum": 3 + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "format": { + "type": "object", + "additionalProperties": false, + "required": [ + "encoding", + "sample_rate_hz", + "channels" + ], + "properties": { + "encoding": { + "enum": [ + "pcm_s16le", + "pcma" + ] + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000 + }, + "channels": { + "const": 1 + } + } + } + } + }, + "prompt": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "allowed_variables" + ], + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "allowed_variables": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "max_bytes": { + "type": "integer", + "minimum": 1 + } + } + }, + "hangup_keyword": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "triggers", + "closingRemark" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "triggers": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "closingRemark": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + } + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "properties": { + "opening": { + "type": "string" + }, + "hangup_keywords": { + "type": "array", + "items": { + "$ref": "#/$defs/hangup_keyword" + } + }, + "allow_interrupt": { + "type": "boolean" + }, + "silence_timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "max_duration_ms": { + "type": "integer", + "minimum": 1 + }, + "max_turns": { + "type": "integer", + "minimum": 1 + }, + "sentence_max_chars": { + "type": "integer", + "minimum": 1 + }, + "max_pending_audio_chunks": { + "type": "integer", + "minimum": 1 + } + } + } + } +} diff --git a/contracts/schema/http-task-list.schema.json b/contracts/schema/http-task-list.schema.json new file mode 100644 index 0000000..f5a55e6 --- /dev/null +++ b/contracts/schema/http-task-list.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/http-task-list.schema.json", + "title": "GET /internal/v1/dispatcher/tasks response; external SaaS compatibility unverified", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "dispatcher_id", + "cursor", + "tasks" + ], + "properties": { + "schema_version": {}, + "control_seq": {}, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "cursor": { + "type": "string", + "minLength": 1 + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/task" + } + } + }, + "$comment": "A page containing zero tasks and a cursor ends enumeration. Non-empty short pages never end enumeration. Persist non-empty pages before using their returned cursor." + }, + { + "$ref": "./common.schema.json#/$defs/error" + } + ], + "$defs": { + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "task_id", + "tenant_id", + "status", + "task_revision" + ], + "properties": { + "schema_version": {}, + "control_seq": {}, + "task_id": { + "$ref": "./common.schema.json#/$defs/task_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "status": { + "$ref": "./common.schema.json#/$defs/task_status" + }, + "task_revision": { + "type": "integer", + "minimum": 1 + } + } + } + } +} diff --git a/contracts/schema/http-tenant-quota.schema.json b/contracts/schema/http-tenant-quota.schema.json new file mode 100644 index 0000000..c7e9922 --- /dev/null +++ b/contracts/schema/http-tenant-quota.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/http-tenant-quota.schema.json", + "title": "GET /internal/v1/dispatcher/tenant/:tenant_id/quota response; external SaaS compatibility unverified", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "dispatcher_id", + "tenant_id", + "quota_revision", + "max_concurrent_calls" + ], + "properties": { + "resource": { + "const": "tenant_quota" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "quota_revision": { + "type": "integer", + "minimum": 1 + }, + "max_concurrent_calls": { + "type": "integer", + "minimum": 0 + } + } + }, + { + "$ref": "./common.schema.json#/$defs/error" + } + ] +} diff --git a/contracts/schema/mq-call-execute-receipt.schema.json b/contracts/schema/mq-call-execute-receipt.schema.json new file mode 100644 index 0000000..88c30b8 --- /dev/null +++ b/contracts/schema/mq-call-execute-receipt.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/mq-call-execute-receipt.schema.json", + "title": "Dispatcher to SaaS: call.execute dispatch/rejection receipt; external SaaS compatibility unverified", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "event_type", + "dispatcher_id", + "tenant_id", + "issued_at", + "payload" + ], + "properties": { + "event_id": { + "$ref": "./common.schema.json#/$defs/event_id" + }, + "event_type": { + "const": "call.execute" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "issued_at": { + "$ref": "./common.schema.json#/$defs/issued_at" + }, + "payload": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "const": "dispatched" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "reason_code", + "reason_message" + ], + "properties": { + "status": { + "const": "rejected" + }, + "reason_code": { + "type": "null" + }, + "reason_message": { + "type": "string", + "minLength": 1 + } + } + } + ] + } + } +} diff --git a/contracts/schema/mq-call-execute-request.schema.json b/contracts/schema/mq-call-execute-request.schema.json new file mode 100644 index 0000000..6200d46 --- /dev/null +++ b/contracts/schema/mq-call-execute-request.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/mq-call-execute-request.schema.json", + "title": "SaaS to Dispatcher: call.execute request; external SaaS compatibility unverified", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "event_type", + "dispatcher_id", + "tenant_id", + "issued_at", + "payload" + ], + "properties": { + "event_id": { + "$ref": "./common.schema.json#/$defs/event_id" + }, + "event_type": { + "const": "call.execute" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "issued_at": { + "$ref": "./common.schema.json#/$defs/issued_at" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "task_id", + "callee" + ], + "properties": { + "task_id": { + "$ref": "./common.schema.json#/$defs/task_id" + }, + "callee": { + "type": "string", + "minLength": 1, + "description": "SaaS event original callee; Dispatcher rejects non-ASCII digits or more than 32 digits with an application receipt after persisting the event" + } + } + } + } +} diff --git a/contracts/schema/mq-call-execute-result.schema.json b/contracts/schema/mq-call-execute-result.schema.json new file mode 100644 index 0000000..8657ce1 --- /dev/null +++ b/contracts/schema/mq-call-execute-result.schema.json @@ -0,0 +1,248 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/mq-call-execute-result.schema.json", + "title": "Dispatcher to SaaS: call.execute.result final result; external SaaS compatibility unverified", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "event_type", + "dispatcher_id", + "tenant_id", + "issued_at", + "payload" + ], + "properties": { + "event_id": { + "$ref": "./common.schema.json#/$defs/event_id" + }, + "event_type": { + "const": "call.execute.result" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "issued_at": { + "$ref": "./common.schema.json#/$defs/issued_at" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "task_id", + "callee", + "trunk_id", + "started_at", + "ended_at", + "duration_ms", + "outcome", + "reason_code", + "status_line", + "raw", + "sip_capture_error", + "transcript", + "opt_out", + "recording" + ], + "properties": { + "task_id": { + "$ref": "./common.schema.json#/$defs/task_id" + }, + "callee": { + "type": "string", + "minLength": 1 + }, + "trunk_id": { + "type": "string", + "minLength": 1 + }, + "started_at": { + "$ref": "./common.schema.json#/$defs/issued_at" + }, + "ended_at": { + "$ref": "./common.schema.json#/$defs/issued_at" + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "outcome": { + "enum": [ + "answered", + "no_answer", + "failed" + ] + }, + "reason_code": { + "type": [ + "integer", + "null" + ], + "minimum": 100, + "maximum": 699 + }, + "reason_message": { + "type": "string", + "minLength": 1 + }, + "status_line": { + "type": [ + "string", + "null" + ], + "pattern": "^SIP/2\\.0 [1-6][0-9]{2} .+", + "maxLength": 256 + }, + "raw": { + "type": [ + "string", + "null" + ], + "minLength": 12, + "maxLength": 65535 + }, + "sip_capture_error": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 256 + }, + "transcript": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "turn_id", + "segment_id", + "role", + "text", + "start_ms", + "end_ms" + ], + "properties": { + "turn_id": { + "type": "string", + "minLength": 1 + }, + "segment_id": { + "type": "string", + "minLength": 1 + }, + "role": { + "enum": [ + "user", + "assistant" + ] + }, + "text": { + "type": "string" + }, + "start_ms": { + "type": "integer", + "minimum": 0 + }, + "end_ms": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "opt_out": { + "type": "boolean" + }, + "recording": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "maxProperties": 0 + }, + { + "$ref": "#/$defs/recording_uploaded" + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "reason_code": { + "type": "null" + } + }, + "required": [ + "reason_code" + ] + }, + "then": { + "required": [ + "reason_message" + ] + } + } + ] + } + }, + "$defs": { + "recording_uploaded": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "bucket", + "object_key", + "format", + "channels", + "sample_rate_hz", + "duration_ms", + "size_bytes", + "checksum_sha256" + ], + "properties": { + "status": { + "const": "uploaded" + }, + "bucket": { + "type": "string", + "minLength": 1 + }, + "object_key": { + "type": "string", + "minLength": 1 + }, + "format": { + "type": "string", + "minLength": 1 + }, + "channels": { + "type": "integer", + "minimum": 1 + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 1 + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "size_bytes": { + "type": "integer", + "minimum": 0 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } +} diff --git a/contracts/schema/mq-sip-config.schema.json b/contracts/schema/mq-sip-config.schema.json new file mode 100644 index 0000000..2ecdb9f --- /dev/null +++ b/contracts/schema/mq-sip-config.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/mq-sip-config.schema.json", + "title": "SaaS to Dispatcher: sip.config notification; external SaaS compatibility unverified", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "event_type", + "dispatcher_id", + "payload" + ], + "properties": { + "event_id": { + "$ref": "./common.schema.json#/$defs/event_id" + }, + "event_type": { + "const": "sip.config" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "trunk_id", + "change_type", + "revision" + ], + "properties": { + "trunk_id": { + "type": "string", + "minLength": 1 + }, + "change_type": { + "enum": [ + "enabled", + "disabled", + "removed", + "created" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + } + } + } + } +} diff --git a/contracts/schema/mq-task-control-receipt.schema.json b/contracts/schema/mq-task-control-receipt.schema.json new file mode 100644 index 0000000..06bc23a --- /dev/null +++ b/contracts/schema/mq-task-control-receipt.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/mq-task-control-receipt.schema.json", + "title": "Dispatcher to SaaS: task.control receipt; external SaaS compatibility unverified", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "event_type", + "dispatcher_id", + "tenant_id", + "payload" + ], + "properties": { + "event_id": { + "$ref": "./common.schema.json#/$defs/event_id" + }, + "event_type": { + "const": "task.control" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/contracts/schema/mq-task-control-request.schema.json b/contracts/schema/mq-task-control-request.schema.json new file mode 100644 index 0000000..4300238 --- /dev/null +++ b/contracts/schema/mq-task-control-request.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/schema/mq-task-control-request.schema.json", + "title": "SaaS to Dispatcher: task.control request; external SaaS compatibility unverified", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "event_type", + "dispatcher_id", + "tenant_id", + "issued_at", + "payload" + ], + "properties": { + "event_id": { + "$ref": "./common.schema.json#/$defs/event_id" + }, + "event_type": { + "const": "task.control" + }, + "dispatcher_id": { + "$ref": "./common.schema.json#/$defs/dispatcher_id" + }, + "tenant_id": { + "$ref": "./common.schema.json#/$defs/tenant_id" + }, + "issued_at": { + "$ref": "./common.schema.json#/$defs/issued_at" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "task_id", + "action", + "reason" + ], + "properties": { + "task_id": { + "$ref": "./common.schema.json#/$defs/task_id" + }, + "action": { + "enum": [ + "start", + "pause", + "resume", + "stop", + "edit" + ] + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "object", + "additionalProperties": false, + "properties": { + "active_call_policy": { + "enum": [ + "drain", + "hangup" + ] + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "edit" + } + } + }, + "then": { + "not": { + "required": [ + "options" + ] + } + } + } + ] + } + } +} diff --git a/deploys/cell/README.md b/deploys/cell/README.md index 1b08014..e41e043 100644 --- a/deploys/cell/README.md +++ b/deploys/cell/README.md @@ -51,7 +51,13 @@ reports its applied revision. Local Mock fixtures do not prove real services. Before every real outbound attempt, the operator must obtain a fresh user confirmation in the current conversation that names the SIP channel, raw target -number and capture plan. Task and trunk schedules and quotas come from the +number and, when `agent --debug/-D` is enabled, capture plan. External tcpdump +and the capture-first wrapper are opt-in debugging tools, not normal-production +requirements. Debug mode still fails closed on missing/invalid capture evidence; +the HEP SIP-response mirror remains independent and required for that response +reporting path. The standalone no-AI line test is documented in +[`cmd/sip-call/README.md`](../../cmd/sip-call/README.md). +Task and trunk schedules and quotas come from the verified SaaS configuration snapshot; the local host has no separate fixed hour or daily-attempt limit. Missing or contradictory schedules fail closed; do not wait, retry, delay or switch trunks. A prior confirmation does not diff --git a/deploys/env/agent.env.example b/deploys/env/agent.env.example index 8b8c578..946f1ae 100644 --- a/deploys/env/agent.env.example +++ b/deploys/env/agent.env.example @@ -1,6 +1,7 @@ # Isolated local Mock only. This is not a production or real-call configuration. -# A non-production validation host still requires the mandatory native Asterisk -# and capture-first diagnostics in deploys/test/nonprod-call-evidence.sh. +# Native Asterisk/host validation remains required. External tcpdump capture +# is opt-in: agent --debug/-D requires the existing capture-first wrapper and +# AGENT_EVIDENCE_ROOT; normal operation has no external capture dependency. # Start explicitly: sip-go-agent agent --mode mock AGENT_ID=agent-mock CELL_ID=cell-mock diff --git a/deploys/test/README.md b/deploys/test/README.md index f4f5d76..3505dbf 100644 --- a/deploys/test/README.md +++ b/deploys/test/README.md @@ -16,7 +16,8 @@ systemd service or add it to a production host. ## Native Asterisk validation -`nonprod-call-evidence.sh` is the mandatory capture-first wrapper for +`nonprod-call-evidence.sh` is the capture-first wrapper used with +`sip-go-agent agent --debug/-D` for non-production `mock`, `mixed` and explicit `nonprod-real` call checks. It runs on a validation host with native Asterisk and required diagnostics; it is not an Asterisk or Agent replacement and is not containerized. Run it explicitly with the current @@ -32,7 +33,7 @@ restricted evidence ownership is recorded in `diagnostic-errors.txt` and fails the check; an already failed call keeps its nonzero result. Once live tcpdump and the PJSIP logger are running, the script creates a root-owned, group-readable `.active` capture arm under `--proof-root` (default -`/run/sip-go-agent/nonprod-armed`). The real Agent must set `AGENT_EVIDENCE_ROOT` +`/run/sip-go-agent/nonprod-armed`). A debug-enabled real Agent must set `AGENT_EVIDENCE_ROOT` to this directory; it checks the exact approved event ID, trunk, raw callee, recent arm and live capture PID before origination. The script removes the arm before stopping capture. Run one approved call per invocation, with @@ -55,8 +56,11 @@ mirror as described in [`cell/README.md`](../cell/README.md), with `AGENT_HEP_LISTEN_ADDR=127.0.0.1:` matching `capture_address` in the private `hep.conf`. Confirm `res_hep` and `res_hep_pjsip` are running and the Agent's UDP listener is bound before any explicitly authorized trial. The -capture-first wrapper remains mandatory; HEP is not a replacement for pcap, -PJSIP logger, call-window checks, or caller approval. Verify the same +capture-first wrapper is required only when Agent `--debug/-D` is enabled; +normal Agent operation does not require tcpdump or external capture proofs. +HEP's native SIP-response mirror, call-window checks and caller approval remain +independent requirements. The standalone no-AI single-call tool and its optional +pcap workflow are documented in [`cmd/sip-call/README.md`](../../cmd/sip-call/README.md). Verify the same `call.execute.result` is present in Dispatcher outbox and the SaaS Mock's restricted result store; inspect full `raw` only there and do not place it in logs, source, chat, or long-term evidence. A missing mirror produces a clear diff --git a/docs/evidence/sip-call-test-deployment-20261008.md b/docs/evidence/sip-call-test-deployment-20261008.md new file mode 100644 index 0000000..3f5f080 --- /dev/null +++ b/docs/evidence/sip-call-test-deployment-20261008.md @@ -0,0 +1,49 @@ +# 独立 sip-call 测试工具部署记录(2026-10-08) + +## 授权与范围 + +使用者明确批准:仅将独立 `sip-call` 和当前 `AGENTS.md` 三条线路的 ENV 部署到既有登记测试机 `rogee` 用户的 `~/sip-call/`。不更新业务 Agent/Dispatcher,不停止、启动或重启业务服务,不加载测试线路,不拨号,不调用 AI。 + +这是非生产测试工具部署,不是线路接通、供应商参数签收、真实媒体或生产验收。`production_approval=false`;真实呼叫和 AI 调用均为 0。 + +## 来源与文件 + +- 基线提交:`98b3fd469d2797d45e83254affbad194f34c5b57`,工作区含未提交改动。不能把基线提交本身当成部署源码。 +- 以 `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false` 独立构建;制品为静态 Linux/amd64 程序。 +- `go mod verify` 通过;实际依赖图中的 68 个本项目编译/嵌入输入已逐文件固定 hash,并在构建后核对未变化。 +- 编译输入清单摘要:`71e85ab543df5921cf6c906091373af847ec0be49efeecdcbb2801b68bba0c2e`。 +- 三份配置由当时当前 `AGENTS.md` 表格生成,使用实际 systemd 声明的 Asterisk 路径;实际 Go ENV 解析器核验 3/3 通过,传输后逐文件核对原字节 hash。 +- 百应按当前表使用 `mka755108`,没有回退至历史 `mka755`。UDP、IP 鉴权、无需 REGISTER、PCMA 是本次明确声明的测试参数,不表示供应商已确认。 + +| 部署文件 | SHA-256 | +| --- | --- | +| `sip-call` | `d1e885077729e38594c19e66dab367976785f55de3e92480ddebb095d5c3eff6` | +| `shuqi.env` | `eefe8311bea3f0d1f9581f370f0515bb2497e3928ac0f88d6034c80037c469ba` | +| `zhongding.env` | `94eddf1891a54bb85c9fb3074873b9f958f67ec723f106893c71ae791e361662` | +| `baiying.env` | `d1c8b244a9e3207ae1906dce08866948468b3c6f8e5b95b6652e2e7307a1c69a` | + +目标原先不存在;在独立临时目录校验后以禁止覆盖的方式发布,没有覆盖既有文件。`~/sip-call/` 与 `results/` 为 0700;三个 ENV、`deployment.json`、`verification.json` 为 0600,程序为 0755,归属 `rogee`。 + +源码输入清单、原 AGENTS 字节、完整部署清单和脱敏只读核验保留在本机私有 `.local/sip-call-deploy-20261008-JYqEHk/`;现场清单为 `~/sip-call/deployment.json` 与 `verification.json`。实际 ENV 不提交。 + +## 主机只读核验 + +- SSH 指纹与登记值一致:`SHA256:F5BqDTk+/Es2pj8vNjM0dwDMHmfcyuTI1trBOfPy6jk`;使用严格核对连接,没有更改用户 SSH 记录。 +- 目标为 Debian 13 / x86_64;ECS metadata 的地域与 EIP 已核对,实例/内网地址仅保留摘要,没有读取云角色凭据。 +- 原生 Asterisk 22.10.1,用户服务 enabled + active,linger=yes;CLI 与本机 ARI 只读核验通过,活动通道为 0,无业务 ARI 应用活动。 +- 程序路径:`/home/rogee/.local/opt/go-sip-asterisk/22.10.1/usr/sbin/asterisk`。 +- 库路径来自 systemd:`/home/rogee/.local/opt/go-sip-asterisk/22.10.1/usr/lib`,不是猜测的 `usr/lib/x86_64-linux-gnu`。 +- 现有 `go-sip-udp` 的 UDP `0.0.0.0:5060` transport 已核对;ARI 凭据仅在受限进程内用于 loopback 只读请求,没有展示、复制或持久化。 +- RTP 运行态端口为 **5000–31000**。该配置目录没有 `rtp.conf`,没有猜用 10000–20000;依据原生 `rtp show settings` 获取事实。 +- `go-sip-no-inbound` dialplan context 不存在。原生配置帮助明确 endpoint `context` 用于入站;独立工具使用直接出站 ARI application,不依赖入站 dialplan,未擅自创建它。 + +既有 Asterisk 程序 SHA-256:`3a9fd2580e9dabdd3a5eddef3a1a1fafbf7bb5616b9ce6e9b65145e47494cd90`。 + +既有 `pjsip.conf` 部署前后同为:`c319476e7e4962008899a11ae0461db282eed3d63871b2d8adcd143812408cf8`。部署后再次确认 Asterisk active、通道 0,没有 `sip-call-managed.conf`,没有加载任何新线路或清理业务状态。 + +## 结果与限制 + +- 远端程序 `call --help` 运行通过,支持 `--sip`、`--debug/-D`。 +- 文件 hash、权限及目标目录验证通过;默认模式不依赖外部抓包工具。 +- 测试机已有 tcpdump,**没有 tshark**。本次未安装额外软件或改动抓包权限;`-D` 抓证尚未就绪,缺少工具时程序明确拒绝拨号,不静默降级。 +- 未执行任何 `call --sip ... <号码>`,没有本次工具发起的外呼,也没有本次采集的通话结果或抓包证据;不能据此次安装声称任一线路已正常接通。 diff --git a/internal/asterisk/ari.go b/internal/asterisk/ari.go index fac94b3..df0eef9 100644 --- a/internal/asterisk/ari.go +++ b/internal/asterisk/ari.go @@ -44,10 +44,21 @@ func (l Loader) ariOptions() (*native.Options, error) { // OpenARI proves that the native server accepts this Agent credential without // creating a channel or dialing. The caller owns and must close the client. func (l Loader) OpenARI() (ari.Client, error) { + return l.openARIApplication("go-sip-agent") +} + +// OpenLineTestARI owns separate subscriptions and never steals the business +// application's WebSocket. It does not originate a channel. +func (l Loader) OpenLineTestARI() (ari.Client, error) { + return l.openARIApplication("sip-call") +} + +func (l Loader) openARIApplication(application string) (ari.Client, error) { options, err := l.ariOptions() if err != nil { return nil, err } + options.Application = application client, err := native.Connect(options) if err != nil { return nil, fmt.Errorf("connect native Agent ARI: %T", err) diff --git a/internal/asterisk/call.go b/internal/asterisk/call.go index 34bdc4e..03040b2 100644 --- a/internal/asterisk/call.go +++ b/internal/asterisk/call.go @@ -17,8 +17,9 @@ import ( "github.com/CyCoreSystems/ari/v5/client/native" ) -// NativeDial contains only the Dispatcher-selected SIP identity and the -// Agent's explicitly configured SLIN16 RTP payload profile. +// NativeDial contains explicit SIP identity and the business Agent's SLIN16 +// profile. Business calls use Dispatcher-selected values; isolated line tests +// use their declared ENV identity and omit media resources. type NativeDial struct { ExecutionID, TrunkID, DialedCallee, CallerID string AnswerTimeout time.Duration @@ -118,8 +119,13 @@ func (l Loader) Originate(ctx context.Context, request NativeDial) (*NativeCall, return dialWithClient(ctx, client, request) } -func dialWithClient(ctx context.Context, client ari.Client, request NativeDial) (_ *NativeCall, err error) { - call := &NativeCall{client: client} +func dialWithClient(ctx context.Context, client ari.Client, request NativeDial) (*NativeCall, error) { + return dialWithOptions(ctx, client, request, false) +} + +// lineTest omits media/AI resources and uses a distinct ARI application. +func dialWithOptions(ctx context.Context, client ari.Client, request NativeDial, lineTest bool) (_ *NativeCall, err error) { + call := &NativeCall{client: client, verifyEnd: lineTest} defer func() { if err != nil { cleanupErr := call.Close() @@ -141,9 +147,13 @@ func dialWithClient(ctx context.Context, client ari.Client, request NativeDial) if request.BindSIPCall == nil { return nil, &NativeCallFailure{Phase: "validate", Cause: errors.New("HEP SIP call binding is required before native Dial")} } - call.Media, err = media.ListenRTPWithFormat("127.0.0.1:0", request.MediaPayloadType, media.FormatSLIN16, 16000) - if err != nil { - return nil, &NativeCallFailure{Phase: "rtp_listen", Cause: err} + if lineTest { + originate.App = "sip-call" + } else { + call.Media, err = media.ListenRTPWithFormat("127.0.0.1:0", request.MediaPayloadType, media.FormatSLIN16, 16000) + if err != nil { + return nil, &NativeCallFailure{Phase: "rtp_listen", Cause: err} + } } key := ari.NewKey(ari.ChannelKey, request.ExecutionID) call.subscription = client.Bus().Subscribe(key, "StasisStart", "ChannelStateChange", "StasisEnd", "ChannelHangupRequest", "ChannelDestroyed") @@ -179,6 +189,11 @@ func dialWithClient(ctx context.Context, client ari.Client, request NativeDial) call.verifyEnd = true // Created channel has no ExternalMedia or bridge yet. return nil, &NativeCallFailure{Phase: "answer_wait", Cause: err} } + if lineTest { + call.monitorDone = make(chan struct{}) + go call.watch(request.ExecutionID) + return call, nil + } bridgeKey := ari.NewKey(ari.BridgeKey, request.ExecutionID+"-bridge") call.bridge, err = client.Bridge().Create(bridgeKey, "mixing", "go-sip-agent") if err != nil { @@ -254,6 +269,12 @@ func (c *NativeCall) watch(channelID string) { } } +// EndConfirmed reports an observed ChannelDestroyed or post-Hangup ARI 404. +// An accepted Hangup request alone is not evidence of termination. +func (c *NativeCall) EndConfirmed() bool { + return c != nil && (c.destroyed.Load() || c.confirmedEnd) +} + // Close releases each resource once. Errors remain visible to the caller; a // failed Hangup never silently turns an uncertain call into a success. func (c *NativeCall) Close() error { diff --git a/internal/asterisk/line.go b/internal/asterisk/line.go new file mode 100644 index 0000000..4c478fa --- /dev/null +++ b/internal/asterisk/line.go @@ -0,0 +1,13 @@ +package asterisk + +import ( + "context" + "github.com/CyCoreSystems/ari/v5" +) + +// DialLineTest is the independent sip-call application's single native Dial. +// The caller owns host-idle checks, temporary SIP configuration and optional +// capture. No ExternalMedia, bridge, AI session or recording is created. +func DialLineTest(ctx context.Context, client ari.Client, request NativeDial) (*NativeCall, error) { + return dialWithOptions(ctx, client, request, true) +} diff --git a/internal/asterisk/line_test.go b/internal/asterisk/line_test.go new file mode 100644 index 0000000..de67d59 --- /dev/null +++ b/internal/asterisk/line_test.go @@ -0,0 +1,42 @@ +package asterisk + +import ( + "context" + "github.com/CyCoreSystems/ari/v5" + "testing" + "time" +) + +func TestLineTestUsesIsolatedApplicationAndNoMediaOrAI(t *testing.T) { + events := make(chan ari.Event, 3) + channels := &testChannels{events: events, dataErr: nativeHTTPStatusError{code: 404}} + client := &testARIClient{channels: channels, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: events}}} + call, err := dialWithOptions(context.Background(), client, NativeDial{ExecutionID: "siptest-1", TrunkID: "siptest-shuqi", DialedCallee: "708918601010101", CallerID: "BD123", AnswerTimeout: time.Second, BindSIPCall: testBindSIPCall}, true) + if err != nil { + t.Fatal(err) + } + if channels.issued != 1 || channels.create.App != "sip-call" || call.Media != nil || len(client.bridges.attached) != 0 || channels.media.App != "" { + t.Fatal("line test must send one Dial without ExternalMedia, bridge, or business application") + } + if err = call.Close(); err != nil { + t.Fatal(err) + } + if !call.EndConfirmed() { + t.Fatal("teardown must be confirmed") + } +} + +func TestLineTestDoesNotInventConfirmedTermination(t *testing.T) { + events := make(chan ari.Event, 3) + client := &testARIClient{channels: &testChannels{events: events}, bridges: &testBridges{}, bus: &testBus{sub: answerEvents{events: events}}} + call, err := dialWithOptions(context.Background(), client, NativeDial{ExecutionID: "siptest-1", TrunkID: "siptest-shuqi", DialedCallee: "708918601010101", CallerID: "BD123", AnswerTimeout: time.Second, BindSIPCall: testBindSIPCall}, true) + if err != nil { + t.Fatal(err) + } + if err = call.Close(); err != nil { + t.Fatal(err) + } + if call.EndConfirmed() { + t.Fatal("successful Hangup HTTP is not proof that the channel disappeared") + } +} diff --git a/internal/config/agent_debug_test.go b/internal/config/agent_debug_test.go new file mode 100644 index 0000000..ec76d51 --- /dev/null +++ b/internal/config/agent_debug_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "strings" + "testing" +) + +func TestAgentDebugEvidenceIsOptIn(t *testing.T) { + setAgentEnvironment(t) + t.Setenv("AGENT_MOCK_SCENARIO_FILE", "") + t.Setenv("AGENT_MOCK_APPLIED_SIP_FILE", "") + for k, v := range map[string]string{"ASTERISK_CONFIG_DIR": "/tmp/asterisk-config", "ASTERISK_BIN": "/tmp/asterisk", "ASTERISK_LIBRARY_DIR": "/tmp/lib", "AGENT_OSS_ALLOWED_HOST": "test.oss-cn-beijing.aliyuncs.com", "AGENT_HEP_LISTEN_ADDR": "127.0.0.1:19060", "AGENT_EVIDENCE_ROOT": ""} { + t.Setenv(k, v) + } + s, err := LoadAgentEnvironment("nonprod-real") + if err != nil { + t.Fatalf("normal operation must not require external capture: %v", err) + } + if err = s.ValidateDebug(); err != nil { + t.Fatal(err) + } + s.Debug = true + if err = s.ValidateDebug(); err == nil || !strings.Contains(err.Error(), "AGENT_EVIDENCE_ROOT") { + t.Fatalf("debug requires an absolute evidence root: %v", err) + } + s.EvidenceRoot = "/tmp/evidence" + if err = s.ValidateDebug(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/agent_runtime.go b/internal/config/agent_runtime.go index 2ea6707..6e5e15b 100644 --- a/internal/config/agent_runtime.go +++ b/internal/config/agent_runtime.go @@ -15,6 +15,7 @@ import ( // AgentEnvironment is deployment-owned and is inspected without opening any // durable file, media adapter, credential or network connection. type AgentEnvironment struct { + Debug bool Mode string AgentID string CellID string @@ -99,9 +100,7 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) { *field.value = value } if mode == "nonprod-real" { - if settings.EvidenceRoot, err = get("AGENT_EVIDENCE_ROOT"); err != nil { - return AgentEnvironment{}, err - } + settings.EvidenceRoot = os.Getenv("AGENT_EVIDENCE_ROOT") if settings.OSSAllowedHost, err = get("AGENT_OSS_ALLOWED_HOST"); err != nil { return AgentEnvironment{}, err } @@ -122,9 +121,6 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) { if !strings.HasPrefix(settings.HEPListen, "127.0.0.1:") || !localGRPCAddress(settings.HEPListen, false) { return AgentEnvironment{}, errors.New("AGENT_HEP_LISTEN_ADDR must be an explicit IPv4 loopback address with a nonzero port") } - if !filepath.IsAbs(settings.EvidenceRoot) { - return AgentEnvironment{}, errors.New("AGENT_EVIDENCE_ROOT must be absolute") - } if os.Getenv("AGENT_MOCK_SCENARIO_FILE") != "" || os.Getenv("AGENT_MOCK_APPLIED_SIP_FILE") != "" { return AgentEnvironment{}, errors.New("nonprod-real Agent cannot load isolated Mock fixtures") } @@ -140,6 +136,14 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) { return settings, nil } +// ValidateDebug only requires external capture configuration when explicitly enabled. +func (s AgentEnvironment) ValidateDebug() error { + if s.Debug && s.Mode == "nonprod-real" && !filepath.IsAbs(s.EvidenceRoot) { + return errors.New("AGENT_EVIDENCE_ROOT must be absolute when --debug/-D is enabled") + } + return nil +} + func localGRPCAddress(raw string, allowZeroPort bool) bool { host, port, err := net.SplitHostPort(raw) if err != nil { diff --git a/internal/config/agent_runtime_test.go b/internal/config/agent_runtime_test.go index 98c4f1b..b688912 100644 --- a/internal/config/agent_runtime_test.go +++ b/internal/config/agent_runtime_test.go @@ -86,7 +86,7 @@ func TestLoadNonprodRealAgentRequiresNativeCallAndEvidenceSettings(t *testing.T) } } t.Setenv("AGENT_HEP_LISTEN_ADDR", "127.0.0.1:19060") - for _, name := range []string{"ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "AGENT_EVIDENCE_ROOT", "AGENT_OSS_ALLOWED_HOST", "AGENT_HEP_LISTEN_ADDR", "DISPATCHER_GRPC_ENDPOINT", "DISPATCHER_GRPC_SERVER_NAME"} { + for _, name := range []string{"ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "AGENT_OSS_ALLOWED_HOST", "AGENT_HEP_LISTEN_ADDR", "DISPATCHER_GRPC_ENDPOINT", "DISPATCHER_GRPC_SERVER_NAME"} { t.Run(name, func(t *testing.T) { t.Setenv(name, "") if _, err := LoadAgentEnvironment("nonprod-real"); err == nil || !strings.Contains(err.Error(), name) { diff --git a/internal/rpc/approved_debug_test.go b/internal/rpc/approved_debug_test.go new file mode 100644 index 0000000..9847284 --- /dev/null +++ b/internal/rpc/approved_debug_test.go @@ -0,0 +1,28 @@ +package rpc + +import ( + "context" + "git.ipao.vip/rogee/go-sip/internal/asterisk" + "testing" + "time" +) + +func TestRealCallDebugControlsExternalEvidenceOnly(t *testing.T) { + for _, debug := range []bool{false, true} { + t.Run(map[bool]string{false: "normal", true: "debug"}[debug], func(t *testing.T) { + fixture, _, _, _, approved := recordedMockFixture(t, nil, time.Second) + approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second + r := &ApprovedRecordedRealCall{Debug: debug, Mirror: asterisk.NewHEPMirror(), MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second, Delivery: fixture.Delivery, originator: func(ctx context.Context, _ asterisk.NativeDial) (realMedia, error) { + t.Fatal("prepare must not dial") + return realMedia{}, nil + }} + _, err := r.Prepare(approved) + if debug && err == nil { + t.Fatal("debug must reject missing capture before accepting execution") + } + if !debug && err != nil { + t.Fatalf("normal calls must not depend on tcpdump: %v", err) + } + }) + } +} diff --git a/internal/rpc/approved_recorded_real.go b/internal/rpc/approved_recorded_real.go index 0f55809..8b12869 100644 --- a/internal/rpc/approved_recorded_real.go +++ b/internal/rpc/approved_recorded_real.go @@ -24,6 +24,7 @@ type ApprovedRecordedRealCall struct { Loader asterisk.Loader MediaPayloadType uint8 EvidenceRoot string + Debug bool MaxWAVBytes int64 ReportTimeout time.Duration Delivery *agent.RecordingDelivery @@ -61,6 +62,8 @@ func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(con if r.Loader.ConfigDir == "" || r.Loader.Asterisk == "" || r.Loader.LibraryDir == "" { return nil, errors.New("native Asterisk configuration required for real call") } + } + if r.Debug { if err := verifyCallEvidence(r.EvidenceRoot, approved.SourceEventID, approved.SelectedTrunkID, approved.Callee); err != nil { return nil, err } @@ -103,7 +106,7 @@ func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(con if ctx == nil || ctx.Err() != nil || !time.Now().Before(approved.DialBefore) { return errors.New("real call instruction expired or cancelled before origination") } - if r.originator == nil { + if r.Debug { if err := verifyCallEvidence(r.EvidenceRoot, approved.SourceEventID, approved.SelectedTrunkID, approved.Callee); err != nil { return err } diff --git a/internal/sipcall/capture.go b/internal/sipcall/capture.go new file mode 100644 index 0000000..5c6b61f --- /dev/null +++ b/internal/sipcall/capture.go @@ -0,0 +1,265 @@ +package sipcall + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +type Evidence struct { + PCAP string `json:"pcap"` + PCAPSHA256 string `json:"pcap_sha256"` + SIPTimeline string `json:"sip_timeline,omitempty"` + Packets int `json:"sip_packets"` + FinalINVITECode int `json:"final_invite_code,omitempty"` + FinalINVITEStatus string `json:"final_invite_status,omitempty"` + RTPFromServer int `json:"rtp_from_server"` + RTPToServer int `json:"rtp_to_server"` + RTPBytes int `json:"rtp_bytes"` + MediaScope string `json:"media_scope"` + RTPStatus string `json:"rtp_status"` +} + +type packetCapture struct { + cmd *exec.Cmd + done chan error + log *os.File + dir, tshark string + config Config +} + +var sipCallID = regexp.MustCompile(`^[A-Za-z0-9_.@:+-]{1,256}$`) + +func startCapture(ctx context.Context, c Config, dir string) (captureSession, error) { + tcpdump, err := exec.LookPath("tcpdump") + if err != nil { + return nil, errors.New("--debug requires tcpdump; no call was dialed") + } + tshark, err := exec.LookPath("tshark") + if err != nil { + return nil, errors.New("--debug requires tshark for call-linked evidence; no call was dialed") + } + log, err := os.OpenFile(filepath.Join(dir, "tcpdump.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + // The narrow filter avoids copying unrelated UDP traffic. RTP sent from a + // different provider media IP is explicitly outside this evidence's scope. + cmd := exec.Command(tcpdump, "-i", c.Interface, "-nn", "-U", "-s", "0", "-w", filepath.Join(dir, "capture.pcap"), "udp and host "+c.Server) + cmd.Stdout = log + cmd.Stderr = log + if err = cmd.Start(); err != nil { + return nil, errors.Join(fmt.Errorf("start tcpdump: %w", err), log.Close()) + } + cap := &packetCapture{cmd: cmd, done: make(chan error, 1), log: log, dir: dir, tshark: tshark, config: c} + go func() { cap.done <- cmd.Wait(); close(cap.done) }() + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(25 * time.Millisecond) + defer tick.Stop() + for { + select { + case e := <-cap.done: + return nil, errors.Join(errors.New("tcpdump stopped before readiness; inspect private tcpdump.log"), e, log.Close()) + case <-ctx.Done(): + _, stopErr := cap.Stop(context.Background(), "") + return nil, errors.Join(ctx.Err(), stopErr) + case <-deadline.C: + _, stopErr := cap.Stop(context.Background(), "") + return nil, errors.Join(errors.New("tcpdump readiness timed out; no call was dialed"), stopErr) + case <-tick.C: + text, e := os.ReadFile(filepath.Join(dir, "tcpdump.log")) + if e != nil { + continue + } + info, e := os.Stat(filepath.Join(dir, "capture.pcap")) + if e == nil && info.Size() >= 24 && bytes.Contains(text, []byte("listening on ")) { + if e = os.Chmod(filepath.Join(dir, "capture.pcap"), 0600); e != nil { + _, stopErr := cap.Stop(context.Background(), "") + return nil, errors.Join(e, stopErr) + } + return cap, nil + } + } + } +} +func (c *packetCapture) Check() error { + select { + case <-c.done: + return errors.New("tcpdump is no longer active; no new Dial is permitted") + default: + return nil + } +} +func (c *packetCapture) Stop(ctx context.Context, callID string) (e Evidence, err error) { + e = Evidence{PCAP: filepath.Join(c.dir, "capture.pcap"), MediaScope: "UDP to/from configured SIP server IP only; different RTP media IPs are not covered", RTPStatus: "not_observed_within_capture_scope"} + select { + case waitErr := <-c.done: + err = errors.Join(errors.New("tcpdump exited before capture finalization"), waitErr) + default: + signalErr := c.cmd.Process.Signal(syscall.SIGINT) + if signalErr != nil { + err = errors.Join(err, signalErr) + } + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + select { + case waitErr := <-c.done: + err = errors.Join(err, waitErr) + case <-ctx.Done(): + err = errors.Join(err, ctx.Err(), c.cmd.Process.Kill(), <-c.done) + case <-timeout.C: + err = errors.Join(err, errors.New("tcpdump did not stop within 5 seconds"), c.cmd.Process.Kill(), <-c.done) + } + } + err = errors.Join(err, c.log.Close()) + file, readErr := os.Open(e.PCAP) + if readErr == nil { + hash := sha256.New() + _, readErr = io.Copy(hash, file) + readErr = errors.Join(readErr, file.Close()) + if readErr == nil { + e.PCAPSHA256 = hex.EncodeToString(hash.Sum(nil)) + } + } + err = errors.Join(err, readErr) + if callID == "" { + return e, err + } + if !sipCallID.MatchString(callID) { + return e, errors.Join(err, errors.New("native SIP Call-ID cannot be safely correlated")) + } + args := []string{"-n", "-r", e.PCAP, "-d", fmt.Sprintf("udp.port==%d,sip", c.config.Port), "-Y", `sip.Call-ID == "` + callID + `"`, "-T", "fields", "-E", "separator=/t", "-E", "occurrence=f"} + for _, field := range []string{"frame.number", "frame.time_epoch", "ip.src", "ip.dst", "sip.Request-Line", "sip.Status-Code", "sip.CSeq.method", "sip.CSeq.seq", "sip.Via.branch", "sip.Status-Line", "sdp.connection_info.address", "sdp.media.media", "sdp.media.port"} { + args = append(args, "-e", field) + } + timeline, decodeErr := runTshark(ctx, c.tshark, args...) + err = errors.Join(err, decodeErr) + e.SIPTimeline = filepath.Join(c.dir, "sip.tsv") + err = errors.Join(err, os.WriteFile(e.SIPTimeline, timeline, 0600)) + if decodeErr != nil { + return e, err + } + parsed, ports, parseErr := parseTimeline(timeline) + e.Packets = parsed.Packets + e.FinalINVITECode = parsed.FinalINVITECode + e.FinalINVITEStatus = parsed.FinalINVITEStatus + err = errors.Join(err, parseErr) + if len(ports) == 0 { + return e, err + } + filters := []string{} + for _, port := range ports { + filters = append(filters, fmt.Sprintf("udp.port == %d", port)) + } + rtpArgs := []string{"-n", "-r", e.PCAP, "-d", fmt.Sprintf("udp.port==%d,sip", c.config.Port)} + for _, port := range ports { + rtpArgs = append(rtpArgs, "-d", fmt.Sprintf("udp.port==%d,rtp", port)) + } + rtpArgs = append(rtpArgs, "-Y", "rtp && ("+strings.Join(filters, " || ")+")", "-T", "fields", "-E", "separator=/t", "-e", "frame.len", "-e", "ip.src", "-e", "ip.dst") + rtp, rtpErr := runTshark(ctx, c.tshark, rtpArgs...) + err = errors.Join(err, rtpErr, os.WriteFile(filepath.Join(c.dir, "rtp.tsv"), rtp, 0600)) + for _, line := range strings.Split(strings.TrimSpace(string(rtp)), "\n") { + if line == "" { + continue + } + fields := strings.Split(line, "\t") + if len(fields) != 3 { + return e, errors.Join(err, errors.New("unexpected tshark RTP fields")) + } + n, convErr := strconv.Atoi(fields[0]) + if convErr != nil { + return e, errors.Join(err, convErr) + } + e.RTPBytes += n + if fields[1] == c.config.Server { + e.RTPFromServer++ + } + if fields[2] == c.config.Server { + e.RTPToServer++ + } + } + if e.RTPFromServer+e.RTPToServer > 0 { + e.RTPStatus = "observed_packets_only; no audio intelligibility or AI test" + } + return e, err +} +func runTshark(ctx context.Context, binary string, args ...string) ([]byte, error) { + bounded, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(bounded, binary, args...) + var diagnostic bytes.Buffer + cmd.Stderr = &diagnostic + out, err := cmd.Output() + if err != nil { + for i, arg := range args { + if arg == "-r" && i+1 < len(args) { + err = errors.Join(err, os.WriteFile(filepath.Join(filepath.Dir(args[i+1]), "tshark.log"), diagnostic.Bytes(), 0600)) + break + } + } + return nil, fmt.Errorf("tshark evidence extraction failed; inspect private tshark.log: %w", err) + } + return out, nil +} + +// parseTimeline consumes decoded tshark fields, not a hand-written SIP parser. +// Final responses are tied to the original INVITE's CSeq and Via transaction. +func parseTimeline(data []byte) (Evidence, []int, error) { + e := Evidence{} + var rows [][]string + var seq, branch string + ports := []int{} + seen := map[int]bool{} + for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { + if line == "" { + continue + } + fields := strings.Split(line, "\t") + if len(fields) != 13 { + return e, nil, errors.New("unexpected tshark SIP fields") + } + rows = append(rows, fields) + e.Packets++ + if seq == "" && strings.HasPrefix(fields[4], "INVITE ") { + seq, branch = fields[7], fields[8] + } + if fields[11] == "audio" { + p, err := strconv.Atoi(fields[12]) + if err == nil && p > 0 && p <= 65535 && !seen[p] { + seen[p] = true + ports = append(ports, p) + } + } + } + if seq == "" || branch == "" { + return e, ports, errors.New("capture contains no identifiable original INVITE for this SIP Call-ID") + } + for _, fields := range rows { + if fields[6] != "INVITE" || fields[7] != seq || fields[8] != branch { + continue + } + code, convErr := strconv.Atoi(fields[5]) + if convErr != nil || code < 200 { + continue + } + if e.FinalINVITECode != 0 && e.FinalINVITECode != code { + return e, ports, errors.New("conflicting final responses for original INVITE transaction") + } + e.FinalINVITECode = code + e.FinalINVITEStatus = fields[9] + } + return e, ports, nil +} diff --git a/internal/sipcall/capture_test.go b/internal/sipcall/capture_test.go new file mode 100644 index 0000000..20ce91f --- /dev/null +++ b/internal/sipcall/capture_test.go @@ -0,0 +1,125 @@ +package sipcall + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const testTimeline = "1\t1.0\t192.0.2.1\t192.0.2.10\tINVITE sip:186@192.0.2.10 SIP/2.0\t\tINVITE\t10\tz9hG4bK-test\t\t192.0.2.1\taudio\t10000\n2\t1.1\t192.0.2.10\t192.0.2.1\t\t200\tINVITE\t10\tz9hG4bK-test\tSIP/2.0 200 OK\t192.0.2.10\taudio\t12000\n3\t2.1\t192.0.2.10\t192.0.2.1\t\t200\tBYE\t11\tz9hG4bK-bye\tSIP/2.0 200 OK\t\t\t\n" + +func TestDecodedEvidenceUsesOriginalINVITEAndNotBYE(t *testing.T) { + e, ports, err := parseTimeline([]byte(testTimeline)) + if err != nil || e.Packets != 3 || e.FinalINVITECode != 200 || len(ports) != 2 { + t.Fatalf("%+v %v %v", e, ports, err) + } + bad := strings.Replace(testTimeline, "\t200\tINVITE\t10\t", "\t480\tINVITE\t10\t", 1) + e, _, err = parseTimeline([]byte(bad)) + if err != nil || e.FinalINVITECode != 480 { + t.Fatal("BYE 200 must not turn a rejected INVITE into a connected call") + } + if _, _, err = parseTimeline([]byte("unexpected\n")); err == nil { + t.Fatal("malformed decoder output accepted") + } + if _, _, err = parseTimeline(nil); err == nil { + t.Fatal("missing original INVITE accepted") + } + conflict := testTimeline + strings.Replace(strings.Split(testTimeline, "\n")[1], "\t200\t", "\t480\t", 1) + "\n" + if _, _, err = parseTimeline([]byte(conflict)); err == nil { + t.Fatal("conflicting final INVITE response accepted") + } +} + +func fakeTools(t *testing.T, broken bool) string { + t.Helper() + dir := t.TempDir() + tcpdump := `#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-w" ]; then shift; out="$1"; fi + shift +done +/bin/dd if=/dev/zero of="$out" bs=24 count=1 2>/dev/null +echo "tcpdump: listening on any" >&2 +trap 'exit 0' INT TERM +while :; do /bin/sleep 0.05; done +` + if broken { + tcpdump = "#!/bin/sh\necho 'permission denied' >&2\nexit 1\n" + } + decoder := "#!/bin/sh\ncase \"$*\" in\n*'rtp &&'*) printf '160\\t192.0.2.10\\t192.0.2.1\\n160\\t192.0.2.1\\t192.0.2.10\\n' ;;\n*) /bin/cat '" + filepath.Join(dir, "timeline.tsv") + "' ;;\nesac\n" + for name, data := range map[string]string{"tcpdump": tcpdump, "tshark": decoder, "timeline.tsv": testTimeline} { + mode := os.FileMode(0700) + if name == "timeline.tsv" { + mode = 0600 + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(data), mode); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) + return dir +} + +func TestDebugCaptureOwnsProcessAndProducesPrivateLinkedEvidence(t *testing.T) { + fakeTools(t, false) + c := fixtureConfig(t) + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cap, err := startCapture(ctx, c, dir) + if err != nil { + t.Fatal(err) + } + if err = cap.Check(); err != nil { + t.Fatal(err) + } + e, err := cap.Stop(ctx, "sip-id-123") + if err != nil { + t.Fatal(err) + } + if len(e.PCAPSHA256) != 64 || e.FinalINVITECode != 200 || e.RTPFromServer != 1 || e.RTPToServer != 1 || e.RTPBytes != 320 { + t.Fatalf("wrong evidence: %+v", e) + } + for _, path := range []string{e.PCAP, e.SIPTimeline, filepath.Join(dir, "tcpdump.log"), filepath.Join(dir, "rtp.tsv")} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("private artifact expected: %s %v", path, err) + } + } + if err = cap.Check(); err == nil { + t.Fatal("ended capture still reported ready") + } +} + +func TestDebugCaptureFailureDoesNotSilentlyDisableEvidence(t *testing.T) { + fakeTools(t, true) + c := fixtureConfig(t) + if _, err := startCapture(context.Background(), c, t.TempDir()); err == nil { + t.Fatal("broken tcpdump accepted") + } + t.Setenv("PATH", t.TempDir()) + if _, err := startCapture(context.Background(), c, t.TempDir()); err == nil { + t.Fatal("missing tcpdump accepted") + } +} + +func TestDebugCaptureRejectsUnsafeCallIDAndCancelledStartup(t *testing.T) { + fakeTools(t, false) + c := fixtureConfig(t) + cap, err := startCapture(context.Background(), c, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err = cap.Stop(context.Background(), "id\" || ip"); err == nil { + t.Fatal("unsafe filter correlation accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = startCapture(ctx, c, t.TempDir()); err == nil { + t.Fatal("cancelled capture accepted") + } +} diff --git a/internal/sipcall/config.go b/internal/sipcall/config.go new file mode 100644 index 0000000..2a4bd4d --- /dev/null +++ b/internal/sipcall/config.go @@ -0,0 +1,145 @@ +// Package sipcall implements the isolated, single-call native Asterisk line test. +package sipcall + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/netip" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +type Config struct { + ID, Server, CallerID, Prefix string + Port int + ConfigDir, Binary, LibraryDir, OutputDir, Interface string + Ring, Hold time.Duration + resultDir string +} + +var identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$`) +var caller = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$`) +var prefix = regexp.MustCompile(`^[A-Za-z0-9]{0,16}$`) +var numberPattern = regexp.MustCompile(`^[0-9]{1,32}$`) + +func LoadConfig(path string) (Config, error) { + info, err := os.Lstat(path) + if err != nil { + return Config{}, fmt.Errorf("inspect SIP ENV: %w", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + return Config{}, errors.New("SIP ENV must be a private regular file (0600), not a symlink") + } + f, err := os.Open(path) + if err != nil { + return Config{}, err + } + defer f.Close() + return ParseConfig(f) +} + +// ParseConfig never evaluates shell syntax, substitutes variables, or accepts +// unknown/duplicate fields. Empty prefix must still be explicitly declared. +func ParseConfig(r io.Reader) (Config, error) { + keys := []string{"SIP_ID", "SIP_SERVER", "SIP_PORT", "SIP_CALLER_ID", "SIP_PREFIX", "SIP_TRANSPORT", "SIP_AUTH", "SIP_REGISTER", "SIP_CODEC", "ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "OUTPUT_DIR", "RING_SECONDS", "HOLD_SECONDS", "DEBUG_INTERFACE"} + allowed := map[string]bool{} + for _, k := range keys { + allowed[k] = true + } + values := map[string]string{} + data, err := io.ReadAll(io.LimitReader(r, 65537)) + if err != nil { + return Config{}, err + } + if len(data) > 65536 { + return Config{}, errors.New("SIP ENV exceeds 64 KiB") + } + scanner := bufio.NewScanner(strings.NewReader(string(data))) + line := 0 + for scanner.Scan() { + line++ + s := strings.TrimSpace(scanner.Text()) + if s == "" || strings.HasPrefix(s, "#") { + continue + } + k, v, ok := strings.Cut(s, "=") + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + if !ok || !allowed[k] { + return Config{}, fmt.Errorf("SIP ENV line %d: unknown or malformed field", line) + } + if _, ok = values[k]; ok { + return Config{}, fmt.Errorf("SIP ENV line %d: duplicate %s", line, k) + } + if strings.ContainsAny(v, "\x00\r\n\"'`$") { + return Config{}, fmt.Errorf("SIP ENV %s: shell syntax and control characters are not supported", k) + } + values[k] = v + } + if err := scanner.Err(); err != nil { + return Config{}, fmt.Errorf("read SIP ENV: %w", err) + } + for _, k := range keys { + if k == "DEBUG_INTERFACE" { + continue + } + v, ok := values[k] + if !ok || (v == "" && k != "SIP_PREFIX") { + return Config{}, fmt.Errorf("SIP ENV %s required", k) + } + } + for k, want := range map[string]string{"SIP_TRANSPORT": "udp", "SIP_AUTH": "ip", "SIP_REGISTER": "no", "SIP_CODEC": "alaw"} { + if values[k] != want { + return Config{}, fmt.Errorf("%s must be %s; other line modes are not supported", k, want) + } + } + addr, err := netip.ParseAddr(values["SIP_SERVER"]) + if err != nil || !addr.Is4() || addr.IsUnspecified() || addr.IsMulticast() { + return Config{}, errors.New("SIP_SERVER must be a unicast IPv4 address") + } + port, err := strconv.Atoi(values["SIP_PORT"]) + if err != nil || port < 1 || port > 65535 { + return Config{}, errors.New("SIP_PORT must be between 1 and 65535") + } + if !identifier.MatchString(values["SIP_ID"]) || !caller.MatchString(values["SIP_CALLER_ID"]) || !prefix.MatchString(values["SIP_PREFIX"]) { + return Config{}, errors.New("invalid SIP_ID, SIP_CALLER_ID or SIP_PREFIX") + } + ring, err := seconds(values["RING_SECONDS"], 120) + if err != nil { + return Config{}, fmt.Errorf("RING_SECONDS: %w", err) + } + hold, err := seconds(values["HOLD_SECONDS"], 30) + if err != nil { + return Config{}, fmt.Errorf("HOLD_SECONDS: %w", err) + } + for _, k := range []string{"ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "OUTPUT_DIR"} { + if !filepath.IsAbs(values[k]) { + return Config{}, fmt.Errorf("%s must be an absolute path", k) + } + } + iface := values["DEBUG_INTERFACE"] + if iface != "" && !regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,64}$`).MatchString(iface) { + return Config{}, errors.New("invalid DEBUG_INTERFACE") + } + return Config{ID: values["SIP_ID"], Server: values["SIP_SERVER"], Port: port, CallerID: values["SIP_CALLER_ID"], Prefix: values["SIP_PREFIX"], ConfigDir: values["ASTERISK_CONFIG_DIR"], Binary: values["ASTERISK_BIN"], LibraryDir: values["ASTERISK_LIBRARY_DIR"], OutputDir: values["OUTPUT_DIR"], Ring: ring, Hold: hold, Interface: iface}, nil +} +func seconds(s string, max int) (time.Duration, error) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > max { + return 0, fmt.Errorf("must be between 1 and %d", max) + } + return time.Duration(n) * time.Second, nil +} +func (c Config) Route(number string) (string, error) { + if !numberPattern.MatchString(number) { + return "", errors.New("callee must contain 1–32 ASCII digits") + } + return c.Prefix + number, nil +} +func (c Config) Endpoint() string { return "siptest-" + c.ID } diff --git a/internal/sipcall/config_test.go b/internal/sipcall/config_test.go new file mode 100644 index 0000000..1df58f1 --- /dev/null +++ b/internal/sipcall/config_test.go @@ -0,0 +1,81 @@ +package sipcall + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const validENV = `SIP_ID=test-line +SIP_SERVER=192.0.2.10 +SIP_PORT=5060 +SIP_CALLER_ID=BD1234 +SIP_PREFIX=7089 +SIP_TRANSPORT=udp +SIP_AUTH=ip +SIP_REGISTER=no +SIP_CODEC=alaw +ASTERISK_CONFIG_DIR=/tmp/asterisk-test +ASTERISK_BIN=/usr/local/sbin/asterisk +ASTERISK_LIBRARY_DIR=/usr/local/lib +OUTPUT_DIR=/tmp/sip-call-results +RING_SECONDS=20 +HOLD_SECONDS=3 +DEBUG_INTERFACE=any +` + +func TestConfigAndOriginalNumberAreStrict(t *testing.T) { + c, err := ParseConfig(strings.NewReader(validENV)) + if err != nil { + t.Fatal(err) + } + route, err := c.Route("18601010101") + if err != nil || route != "708918601010101" { + t.Fatalf("%s %v", route, err) + } + for _, number := range []string{"", "+8618601010101", "123", "1\n2", strings.Repeat("1", 33)} { + if _, err = c.Route(number); err == nil { + t.Fatalf("invalid number accepted: %q", number) + } + } + for _, change := range [][2]string{{"SIP_PORT=5060", "SIP_PORT=0"}, {"SIP_SERVER=192.0.2.10", "SIP_SERVER=example.com"}, {"SIP_TRANSPORT=udp", "SIP_TRANSPORT=tcp"}, {"SIP_AUTH=ip", "SIP_AUTH=digest"}, {"SIP_REGISTER=no", "SIP_REGISTER=yes"}, {"SIP_CODEC=alaw", "SIP_CODEC=ulaw"}, {"SIP_CALLER_ID=BD1234", "SIP_CALLER_ID=x\";evil"}, {"HOLD_SECONDS=3", "HOLD_SECONDS=0"}, {"RING_SECONDS=20", "RING_SECONDS=121"}, {"OUTPUT_DIR=/tmp/sip-call-results", "OUTPUT_DIR=relative"}, {"SIP_PREFIX=7089", "SIP_PREFIX=$HOME"}, {"SIP_ID=test-line", "SIP_ID=../line"}} { + if _, err = ParseConfig(strings.NewReader(strings.Replace(validENV, change[0], change[1], 1))); err == nil { + t.Fatalf("invalid config accepted: %s", change[1]) + } + } + for _, extra := range []string{"UNKNOWN=value\n", "SIP_PORT=5060\n", "export X=yes\n"} { + if _, err = ParseConfig(strings.NewReader(validENV + extra)); err == nil { + t.Fatal("unknown or duplicate key accepted") + } + } + if _, err = ParseConfig(strings.NewReader(strings.Replace(validENV, "SIP_PREFIX=7089\n", "", 1))); err == nil { + t.Fatal("missing explicit empty-capable prefix accepted") + } + if _, err = ParseConfig(strings.NewReader(strings.Replace(validENV, "SIP_PREFIX=7089", "SIP_PREFIX=", 1))); err != nil { + t.Fatal("explicit empty prefix must be accepted") + } +} + +func TestConfigFileMustBePrivateAndNotSymlink(t *testing.T) { + path := filepath.Join(t.TempDir(), "sip.env") + if err := os.WriteFile(path, []byte(validENV), 0600); err != nil { + t.Fatal(err) + } + if _, err := LoadConfig(path); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if _, err := LoadConfig(path); err == nil { + t.Fatal("public config accepted") + } + link := path + ".link" + if err := os.Symlink(path, link); err != nil { + t.Fatal(err) + } + if _, err := LoadConfig(link); err == nil { + t.Fatal("symlink accepted") + } +} diff --git a/internal/sipcall/configuration.go b/internal/sipcall/configuration.go new file mode 100644 index 0000000..f097e73 --- /dev/null +++ b/internal/sipcall/configuration.go @@ -0,0 +1,163 @@ +package sipcall + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type cliFunc func(context.Context, string) ([]byte, error) + +const testInclude = "\n#tryinclude sip-call-managed.conf\n" + +// Configuration is temporary and never overwrites go-sip-managed.conf or its +// revision. A previous unfinished test is a blocker, not an automatic cleanup. +func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore func() error, err error) { + basePath := filepath.Join(c.ConfigDir, "pjsip.conf") + managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf") + info, err := os.Lstat(basePath) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + return nil, errors.New("pjsip.conf must be a private regular file (0600)") + } + base, err := os.ReadFile(basePath) + if err != nil { + return nil, err + } + if bytes.Contains(base, []byte("sip-call-managed.conf")) { + return nil, errors.New("existing sip-call include requires manual diagnosis; refusing to alter it") + } + transport, err := cli(ctx, "pjsip show transports") + if err != nil { + return nil, err + } + found := false + for _, line := range strings.Split(string(transport), "\n") { + fields := strings.Fields(line) + if len(fields) >= 6 && fields[0] == "Transport:" && fields[1] == "go-sip-udp" && fields[2] == "udp" && fields[len(fields)-1] == "0.0.0.0:5060" { + found = true + } + } + if !found { + return nil, errors.New("approved static go-sip-udp transport is absent; no hot change or restart is permitted") + } + text := fmt.Sprintf("; Temporary isolated sip-call endpoint; no authentication or registration.\n[%s]\ntype=endpoint\ntransport=go-sip-udp\ncontext=go-sip-no-inbound\ndisallow=all\nallow=alaw\naors=%s-aor\nfrom_user=%s\ncallerid=\"%s\" <%s>\ndirect_media=no\n\n[%s-aor]\ntype=aor\ncontact=sip:%s:%d\n", c.Endpoint(), c.Endpoint(), c.CallerID, c.CallerID, c.CallerID, c.Endpoint(), c.Server, c.Port) + file, err := os.OpenFile(managed, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return nil, fmt.Errorf("test configuration already exists or cannot be created; do not overwrite: %w", err) + } + _, writeErr := file.WriteString(text) + err = errors.Join(writeErr, file.Sync(), file.Close()) + backup, backupErr := os.OpenFile(filepath.Join(c.resultDir, "pjsip.conf.before"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if backupErr == nil { + _, backupErr = backup.Write(base) + backupErr = errors.Join(backupErr, backup.Sync(), backup.Close()) + } + err = errors.Join(err, backupErr) + appended := append(append([]byte(nil), base...), []byte(testInclude)...) + restore = func() error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + channels, e := cli(cleanupCtx, "core show channels count") + if e != nil { + return e + } + m := activeChannels.FindSubmatch(channels) + if len(m) != 2 || string(m[1]) != "0" { + return errors.New("cannot restore configuration while native channel state is active or uncertain") + } + now, e := os.ReadFile(basePath) + if e != nil { + return e + } + if !bytes.Equal(now, base) && !bytes.Equal(now, appended) { + return errors.New("pjsip.conf changed during test; private pjsip.conf.before preserved, refusing to overwrite concurrent changes") + } + if bytes.Equal(now, appended) { + if e = atomicConfig(basePath, base); e != nil { + return e + } + } + if e := os.Remove(managed); e != nil { + return e + } + if _, e := cli(cleanupCtx, "pjsip reload"); e != nil { + return e + } + out, e := cli(cleanupCtx, "pjsip show endpoint "+c.Endpoint()) + if e != nil { + return e + } + if !strings.Contains(string(out), "Unable to find object") { + return errors.New("test endpoint removal could not be verified") + } + return nil + } + if err != nil { + return restore, err + } + if err = atomicConfig(basePath, appended); err != nil { + return restore, err + } + if _, err = cli(ctx, "pjsip reload"); err != nil { + return restore, err + } + endpoint, err := cli(ctx, "pjsip show endpoint "+c.Endpoint()) + if err != nil { + return restore, err + } + for _, want := range []string{c.Endpoint() + "-aor", "alaw", "go-sip-udp", "go-sip-no-inbound"} { + if !bytes.Contains(endpoint, []byte(want)) { + return restore, errors.New("test endpoint runtime does not match requested AOR/codec/transport/context") + } + } + for _, pair := range [][2]string{{"from_user", c.CallerID}, {"callerid", fmt.Sprintf("\"%s\" <%s>", c.CallerID, c.CallerID)}} { + found := false + for _, line := range strings.Split(string(endpoint), "\n") { + k, v, ok := strings.Cut(line, ":") + if ok && strings.TrimSpace(k) == pair[0] && strings.TrimSpace(v) == pair[1] { + found = true + } + } + if !found { + return restore, errors.New("test endpoint runtime caller identity differs from requested caller") + } + } + aor, err := cli(ctx, "pjsip show aor "+c.Endpoint()+"-aor") + if err != nil { + return restore, err + } + if !bytes.Contains(aor, []byte(fmt.Sprintf("sip:%s:%d", c.Server, c.Port))) { + return restore, errors.New("test AOR runtime does not match requested SIP server") + } + return restore, nil +} + +func atomicConfig(path string, data []byte) error { + f, err := os.CreateTemp(filepath.Dir(path), ".sip-call-write-*") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + _, writeErr := f.Write(data) + err = errors.Join(writeErr, f.Sync(), f.Close()) + if err != nil { + return err + } + if err = os.Rename(name, path); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + return errors.Join(dir.Sync(), dir.Close()) +} diff --git a/internal/sipcall/configuration_test.go b/internal/sipcall/configuration_test.go new file mode 100644 index 0000000..78f3217 --- /dev/null +++ b/internal/sipcall/configuration_test.go @@ -0,0 +1,154 @@ +package sipcall + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func configCLI(c Config, basePath string) cliFunc { + return func(_ context.Context, command string) ([]byte, error) { + switch command { + case "core show channels count": + return []byte("0 active channels\n0 active calls\n"), nil + case "pjsip show transports": + return []byte("Transport: go-sip-udp udp 0 0 0.0.0.0:5060\n"), nil + case "pjsip reload": + return []byte("Module reloaded\n"), nil + case "pjsip show endpoint " + c.Endpoint(): + data, err := os.ReadFile(basePath) + if err != nil { + return nil, err + } + if !bytes.Contains(data, []byte(testInclude)) { + return []byte("Unable to find object " + c.Endpoint()), nil + } + return []byte(fmt.Sprintf("Endpoint: %s\nAor: %s-aor\nallow : (alaw)\ntransport : go-sip-udp\ncontext : go-sip-no-inbound\nfrom_user : %s\ncallerid : \"%s\" <%s>\n", c.Endpoint(), c.Endpoint(), c.CallerID, c.CallerID, c.CallerID)), nil + case "pjsip show aor " + c.Endpoint() + "-aor": + return []byte(fmt.Sprintf("Contact: sip:%s:%d", c.Server, c.Port)), nil + default: + return nil, fmt.Errorf("unexpected CLI: %s", command) + } + } +} +func stageFixture(t *testing.T) (Config, string, []byte) { + t.Helper() + c := fixtureConfig(t) + c.ConfigDir = t.TempDir() + path := filepath.Join(c.ConfigDir, "pjsip.conf") + base := []byte("; original bytes\n[go-sip-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\n") + if err := os.WriteFile(path, base, 0600); err != nil { + t.Fatal(err) + } + return c, path, base +} + +func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) { + c, path, base := stageFixture(t) + restore, err := applyConfiguration(context.Background(), c, configCLI(c, path)) + if err != nil { + t.Fatal(err) + } + managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf") + data, err := os.ReadFile(managed) + if err != nil || !bytes.Contains(data, []byte("from_user=BD1234")) { + t.Fatal("requested identity not configured") + } + if err = restore(); err != nil { + t.Fatal(err) + } + data, err = os.ReadFile(path) + if err != nil || !bytes.Equal(data, base) { + t.Fatal("business config bytes changed") + } + if _, err = os.Stat(managed); !os.IsNotExist(err) { + t.Fatal("temporary file not removed") + } +} + +func TestConfigurationRefusesOldAndConcurrentState(t *testing.T) { + c, path, _ := stageFixture(t) + managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf") + if err := os.WriteFile(managed, []byte("unfinished"), 0600); err != nil { + t.Fatal(err) + } + if _, err := applyConfiguration(context.Background(), c, configCLI(c, path)); err == nil { + t.Fatal("old test state overwritten") + } + if err := os.Remove(managed); err != nil { + t.Fatal(err) + } + restore, err := applyConfiguration(context.Background(), c, configCLI(c, path)) + if err != nil { + t.Fatal(err) + } + concurrent := []byte("concurrent config edit") + if err = os.WriteFile(path, concurrent, 0600); err != nil { + t.Fatal(err) + } + if err = restore(); err == nil { + t.Fatal("concurrent changes swallowed") + } + data, _ := os.ReadFile(path) + if !bytes.Equal(data, concurrent) { + t.Fatal("concurrent bytes overwritten") + } +} + +func TestConfigurationFailsClosedOnRuntimeMismatch(t *testing.T) { + for _, failure := range []string{"transport", "caller", "reload", "aor", "busy"} { + t.Run(failure, func(t *testing.T) { + c, path, base := stageFixture(t) + normal := configCLI(c, path) + cli := func(ctx context.Context, s string) ([]byte, error) { + out, err := normal(ctx, s) + if err != nil { + return nil, err + } + if failure == "transport" && s == "pjsip show transports" { + return []byte("unknown"), nil + } + if failure == "caller" && strings.HasPrefix(s, "pjsip show endpoint") { + return bytes.ReplaceAll(out, []byte("BD1234"), []byte("WRONG")), nil + } + if failure == "reload" && s == "pjsip reload" { + return nil, errors.New("reload failed") + } + if failure == "aor" && strings.HasPrefix(s, "pjsip show aor") { + return []byte("sip:192.0.2.99:5060"), nil + } + if failure == "busy" && s == "core show channels count" { + return []byte("1 active channel"), nil + } + return out, nil + } + restore, err := applyConfiguration(context.Background(), c, cli) + if failure == "busy" { + if err != nil { + t.Fatal(err) + } + if err = restore(); err == nil { + t.Fatal("restoration ignored active channel") + } + return + } + if err == nil { + t.Fatal("runtime mismatch accepted") + } + if restore != nil { + if err = restore(); err != nil && failure != "reload" { + t.Fatal(err) + } + } + data, _ := os.ReadFile(path) + if !bytes.Equal(data, base) { + t.Fatal("failed apply changed original config") + } + }) + } +} diff --git a/internal/sipcall/evidence_consistency_test.go b/internal/sipcall/evidence_consistency_test.go new file mode 100644 index 0000000..39b5e83 --- /dev/null +++ b/internal/sipcall/evidence_consistency_test.go @@ -0,0 +1,27 @@ +package sipcall + +import ( + "context" + "strings" + "testing" +) + +type incompleteCapture struct{ fakeCapture } + +func (c *incompleteCapture) Stop(ctx context.Context, id string) (Evidence, error) { + e, err := c.fakeCapture.Stop(ctx, id) + e.FinalINVITECode = 0 + return e, err +} + +func TestDebugCannotReportSuccessWithMissingINVITEFinalResponse(t *testing.T) { + c := fixtureConfig(t) + h := &fakeHost{} + r, err := run(context.Background(), c, "18601010101", true, h, func(context.Context, Config, string) (captureSession, error) { return &incompleteCapture{}, nil }) + if err == nil || !strings.Contains(err.Error(), "2xx") || r.Status == "connected" { + t.Fatalf("missing final wire evidence disguised as success: %+v %v", r, err) + } + if r.AnsweredAt == nil || !r.EndConfirmed { + t.Fatal("evidence failure must not erase the genuine native call facts") + } +} diff --git a/internal/sipcall/native.go b/internal/sipcall/native.go new file mode 100644 index 0000000..123df7d --- /dev/null +++ b/internal/sipcall/native.go @@ -0,0 +1,104 @@ +package sipcall + +import ( + "context" + "errors" + "fmt" + "git.ipao.vip/rogee/go-sip/internal/asterisk" + "github.com/CyCoreSystems/ari/v5" + "golang.org/x/sys/unix" + "os" + "os/exec" + "path/filepath" + "regexp" + "time" +) + +type nativeHost struct { + config Config + client ari.Client + lock *os.File +} + +var activeChannels = regexp.MustCompile(`(?m)^([0-9]+) active channels?\s*$`) + +func (h *nativeHost) CheckIdle(ctx context.Context) error { + if h.lock == nil { + f, err := os.OpenFile(filepath.Join(h.config.ConfigDir, ".sip-call.lock"), os.O_CREATE|os.O_RDWR|unix.O_NOFOLLOW, 0600) + if err != nil { + return fmt.Errorf("open test lock: %w", err) + } + if err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + return errors.Join(errors.New("another sip-call owns this Asterisk configuration"), f.Close()) + } + h.lock = f + } + data, err := asteriskCLI(ctx, h.config, "core show channels count") + if err != nil { + return err + } + m := activeChannels.FindSubmatch(data) + if len(m) != 2 || string(m[1]) != "0" { + return errors.New("Asterisk idle state unconfirmed or active channels exist; no configuration changes or dialing allowed") + } + if h.client == nil { + h.client, err = (asterisk.Loader{ConfigDir: h.config.ConfigDir}).OpenLineTestARI() + if err != nil { + return err + } + } + apps, err := h.client.Application().List(nil) + if err != nil { + return fmt.Errorf("read native ARI applications: %w", err) + } + for _, app := range apps { + if app == nil || app.ID != "sip-call" { + return errors.New("another ARI application is active; stop business services before the isolated line test") + } + } + return nil +} +func (h *nativeHost) Apply(ctx context.Context, c Config) (func() error, error) { + h.config = c + return applyConfiguration(ctx, c, func(ctx context.Context, s string) ([]byte, error) { return asteriskCLI(ctx, c, s) }) +} +func (h *nativeHost) Dial(ctx context.Context, request asterisk.NativeDial) (testCall, error) { + call, err := asterisk.DialLineTest(ctx, h.client, request) + if err != nil { + return nil, err + } + return &nativeTestCall{call}, nil +} +func (h *nativeHost) Close() error { + if h.client != nil { + h.client.Close() + } + if h.lock != nil { + return errors.Join(unix.Flock(int(h.lock.Fd()), unix.LOCK_UN), h.lock.Close()) + } + return nil +} + +type nativeTestCall struct{ *asterisk.NativeCall } + +func (c *nativeTestCall) Done() context.Context { return c.Context } + +func asteriskCLI(ctx context.Context, c Config, command string) ([]byte, error) { + bounded, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := exec.CommandContext(bounded, c.Binary, "-C", filepath.Join(c.ConfigDir, "asterisk.conf"), "-rx", command) + cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+c.LibraryDir) + out, err := cmd.CombinedOutput() + if c.resultDir != "" { + log, logErr := os.OpenFile(filepath.Join(c.resultDir, "asterisk-cli.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) + if logErr == nil { + _, logErr = fmt.Fprintf(log, "[%s] %s\n%s\n", time.Now().UTC().Format(time.RFC3339Nano), command, out) + logErr = errors.Join(logErr, log.Close()) + } + err = errors.Join(err, logErr) + } + if err != nil { + return nil, fmt.Errorf("Asterisk command %q failed: %w", command, err) + } + return out, nil +} diff --git a/internal/sipcall/native_test.go b/internal/sipcall/native_test.go new file mode 100644 index 0000000..82aba0f --- /dev/null +++ b/internal/sipcall/native_test.go @@ -0,0 +1,76 @@ +package sipcall + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNativeRunnerRejectsMissingAsteriskWithoutDialing(t *testing.T) { + c := fixtureConfig(t) + c.ConfigDir = t.TempDir() + c.Binary = filepath.Join(t.TempDir(), "missing-asterisk") + r, err := Run(context.Background(), c, "18601010101", false) + if err == nil || r == nil || r.Status != "not_dialed" || r.CallID != "" || r.AnsweredAt != nil { + t.Fatalf("invalid native host must never attempt a call: %+v %v", r, err) + } + if _, err = os.Stat(filepath.Join(r.Directory, "result.json")); err != nil { + t.Fatal("native preflight failure needs detail") + } + if r, err = Run(context.Background(), c, "+123", false); err == nil || r != nil { + t.Fatal("invalid input must fail before even opening host state") + } +} + +func TestNativeExclusiveLockAndIdleCheck(t *testing.T) { + c := fixtureConfig(t) + c.ConfigDir = t.TempDir() + c.Binary = filepath.Join(t.TempDir(), "asterisk") + if err := os.WriteFile(c.Binary, []byte("#!/bin/sh\necho '1 active channel'\n"), 0700); err != nil { + t.Fatal(err) + } + first := &nativeHost{config: c} + second := &nativeHost{config: c} + if err := first.CheckIdle(context.Background()); err == nil || !strings.Contains(err.Error(), "active channels") { + t.Fatalf("active call not rejected: %v", err) + } + if err := second.CheckIdle(context.Background()); err == nil || !strings.Contains(err.Error(), "owns") { + t.Fatalf("simultaneous owner not rejected: %v", err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + third := &nativeHost{config: c} + if err := third.CheckIdle(context.Background()); err == nil || strings.Contains(err.Error(), "owns") { + t.Fatalf("lock was not released: %v", err) + } + if err := third.Close(); err != nil { + t.Fatal(err) + } +} + +func TestNativeCLILeavesPrivateDiagnosticsAndDoesNotHideFailure(t *testing.T) { + c := fixtureConfig(t) + c.ConfigDir = t.TempDir() + c.Binary = filepath.Join(t.TempDir(), "asterisk") + if err := os.WriteFile(c.Binary, []byte("#!/bin/sh\necho 'diagnostic command failure' >&2\nexit 3\n"), 0700); err != nil { + t.Fatal(err) + } + if _, err := asteriskCLI(context.Background(), c, "pjsip reload"); err == nil || !strings.Contains(err.Error(), "exit status 3") { + t.Fatalf("command failure hidden: %v", err) + } + path := filepath.Join(c.resultDir, "asterisk-cli.log") + data, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(data), "diagnostic command failure") { + t.Fatalf("missing private root-cause output: %v", err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("native diagnostics must be private") + } +} diff --git a/internal/sipcall/not_dialed_test.go b/internal/sipcall/not_dialed_test.go new file mode 100644 index 0000000..ce27a95 --- /dev/null +++ b/internal/sipcall/not_dialed_test.go @@ -0,0 +1,16 @@ +package sipcall + +import ( + "context" + "errors" + "git.ipao.vip/rogee/go-sip/internal/asterisk" + "testing" +) + +func TestPreDialNativeFailureIsNotReportedAsAttemptedCall(t *testing.T) { + h := &fakeHost{dialErr: &asterisk.NativeCallFailure{Phase: "sip_bind", Cause: errors.New("capture disappeared before Dial")}} + r, err := run(context.Background(), fixtureConfig(t), "18601010101", false, h, nil) + if err == nil || r.Status != "not_dialed" || r.AnsweredAt != nil || h.restores != 1 { + t.Fatalf("never submitted Dial must remain not-dialed: %+v %v", r, err) + } +} diff --git a/internal/sipcall/offline_decoder_test.go b/internal/sipcall/offline_decoder_test.go new file mode 100644 index 0000000..30978cc --- /dev/null +++ b/internal/sipcall/offline_decoder_test.go @@ -0,0 +1,101 @@ +package sipcall + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// This opt-in test uses real tcpdump/tshark only as OFFLINE decoders inside a +// network-disabled disposable container. Packets are synthetic documentation +// addresses: no host capture, SIP traffic, human audio or live service calls. +func TestOfflineRealPacketDecoders(t *testing.T) { + image := os.Getenv("GO_SIP_TSHARK_TEST_IMAGE") + if image == "" { + t.Skip("offline decoder image opt-in not supplied") + } + docker, err := exec.LookPath("docker") + if err != nil { + t.Fatal(err) + } + tools := fakeTools(t, false) + dir := t.TempDir() + source := filepath.Join(tools, "synthetic.pcap") + if err = os.WriteFile(source, syntheticPCAP(), 0600); err != nil { + t.Fatal(err) + } + script, err := os.ReadFile(filepath.Join(tools, "tcpdump")) + if err != nil { + t.Fatal(err) + } + script = bytes.Replace(script, []byte("/bin/dd if=/dev/zero of=\"$out\" bs=24 count=1 2>/dev/null"), []byte("/bin/cp '"+source+"' \"$out\""), 1) + if err = os.WriteFile(filepath.Join(tools, "tcpdump"), script, 0700); err != nil { + t.Fatal(err) + } + decoder := fmt.Sprintf("#!/bin/sh\nexec '%s' run --rm --network none -v '%s:%s:ro' '%s' tshark \"$@\"\n", docker, dir, dir, image) + if err = os.WriteFile(filepath.Join(tools, "tshark"), []byte(decoder), 0700); err != nil { + t.Fatal(err) + } + c := fixtureConfig(t) + cap, err := startCapture(context.Background(), c, dir) + if err != nil { + t.Fatal(err) + } + e, err := cap.Stop(context.Background(), "sip-id-123") + if err != nil { + t.Fatal(err) + } + if e.Packets != 3 || e.FinalINVITECode != 200 || e.RTPFromServer != 1 || e.RTPToServer != 1 { + t.Fatalf("real offline decoding mismatch: %+v", e) + } + cmd := exec.Command(docker, "run", "--rm", "--network", "none", "-v", dir+":"+dir+":ro", image, "tcpdump", "-nn", "-r", e.PCAP) + if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "INVITE") { + t.Fatalf("real tcpdump offline inspection failed: %v %s", err, out) + } +} + +// Static fixture construction, not a runtime SIP/RTP stack. Checksums are +// disabled in these synthetic packets; no packet is ever sent to a socket. +func syntheticPCAP() []byte { + var pcap bytes.Buffer + for _, v := range []any{uint32(0xa1b2c3d4), uint16(2), uint16(4), uint32(0), uint32(0), uint32(65535), uint32(1)} { + _ = binary.Write(&pcap, binary.LittleEndian, v) + } + sdp := func(ip string, port int) string { + return fmt.Sprintf("v=0\r\no=- 1 1 IN IP4 %s\r\ns=offline\r\nc=IN IP4 %s\r\nt=0 0\r\nm=audio %d RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n", ip, ip, port) + } + message := func(first, cseq, branch, body string) []byte { + return []byte(fmt.Sprintf("%s\r\nVia: SIP/2.0/UDP 192.0.2.1:5060;branch=%s\r\nFrom: ;tag=offline\r\nTo: \r\nCall-ID: sip-id-123\r\nCSeq: %s\r\nContent-Type: application/sdp\r\nContent-Length: %d\r\n\r\n%s", first, branch, cseq, len(body), body)) + } + add := func(src, dst byte, sport, dport uint16, payload []byte) { + packet := make([]byte, 14+20+8+len(payload)) + packet[12], packet[13] = 8, 0 + ip := packet[14:34] + ip[0] = 0x45 + binary.BigEndian.PutUint16(ip[2:], uint16(20+8+len(payload))) + ip[8], ip[9] = 64, 17 + copy(ip[12:], []byte{192, 0, 2, src}) + copy(ip[16:], []byte{192, 0, 2, dst}) + udp := packet[34:42] + binary.BigEndian.PutUint16(udp, sport) + binary.BigEndian.PutUint16(udp[2:], dport) + binary.BigEndian.PutUint16(udp[4:], uint16(8+len(payload))) + copy(packet[42:], payload) + for _, v := range []uint32{1, uint32(pcap.Len()), uint32(len(packet)), uint32(len(packet))} { + _ = binary.Write(&pcap, binary.LittleEndian, v) + } + pcap.Write(packet) + } + add(1, 10, 5060, 5060, message("INVITE sip:123@192.0.2.10 SIP/2.0", "10 INVITE", "z9hG4bK-test", sdp("192.0.2.1", 10000))) + add(10, 1, 5060, 5060, message("SIP/2.0 200 OK", "10 INVITE", "z9hG4bK-test", sdp("192.0.2.10", 12000))) + add(10, 1, 5060, 5060, message("SIP/2.0 200 OK", "11 BYE", "z9hG4bK-bye", "")) + add(10, 1, 12000, 10000, append([]byte{0x80, 8, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}, bytes.Repeat([]byte{0xd5}, 160)...)) + add(1, 10, 10000, 12000, append([]byte{0x80, 8, 0, 2, 0, 0, 0, 2, 0, 0, 0, 2}, bytes.Repeat([]byte{0xd5}, 160)...)) + return pcap.Bytes() +} diff --git a/internal/sipcall/run.go b/internal/sipcall/run.go new file mode 100644 index 0000000..3c56950 --- /dev/null +++ b/internal/sipcall/run.go @@ -0,0 +1,204 @@ +package sipcall + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "git.ipao.vip/rogee/go-sip/internal/asterisk" + "os" + "path/filepath" + "time" +) + +type Report struct { + ID string `json:"id"` + Directory string `json:"directory"` + Line string `json:"line"` + Server string `json:"server"` + CallerID string `json:"caller_id"` + Callee string `json:"callee"` + DialedCallee string `json:"dialed_callee"` + CallID string `json:"sip_call_id,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + DialBoundAt *time.Time `json:"pre_dial_bound_at,omitempty"` + AnsweredAt *time.Time `json:"answered_at,omitempty"` + EndedAt *time.Time `json:"end_confirmed_at,omitempty"` + FinishedAt time.Time `json:"finished_at"` + EndConfirmed bool `json:"end_confirmed"` + Debug bool `json:"debug"` + Failure string `json:"failure,omitempty"` + Evidence *Evidence `json:"evidence,omitempty"` +} + +type testCall interface { + Close() error + EndConfirmed() bool + Done() context.Context +} +type host interface { + CheckIdle(context.Context) error + Apply(context.Context, Config) (func() error, error) + Dial(context.Context, asterisk.NativeDial) (testCall, error) + Close() error +} +type captureSession interface { + Stop(context.Context, string) (Evidence, error) + Check() error +} +type captureStarter func(context.Context, Config, string) (captureSession, error) + +// Run invokes exactly one call. Authorization is the operator's explicit +// command; this standalone tool never discovers, queues or retries tasks. +func Run(ctx context.Context, c Config, number string, debug bool) (*Report, error) { + if _, err := c.Route(number); err != nil { + return nil, err + } + h := &nativeHost{config: c} + return run(ctx, c, number, debug, h, startCapture) +} + +func run(ctx context.Context, c Config, number string, debug bool, h host, start captureStarter) (r *Report, err error) { + route, err := c.Route(number) + if err != nil { + return nil, err + } + random := make([]byte, 12) + if _, err = rand.Read(random); err != nil { + return nil, err + } + id := "siptest-" + hex.EncodeToString(random) + if err = ensureOutput(c.OutputDir); err != nil { + return nil, err + } + dir := filepath.Join(c.OutputDir, id) + if err = os.Mkdir(dir, 0700); err != nil { + return nil, err + } + r = &Report{ID: id, Directory: dir, Line: c.ID, Server: fmt.Sprintf("%s:%d", c.Server, c.Port), CallerID: c.CallerID, Callee: number, DialedCallee: route, Status: "not_dialed", StartedAt: time.Now().UTC(), Debug: debug} + var restore func() error + var capture captureSession + safeToRestore := true + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if capture != nil { + e, capErr := capture.Stop(cleanupCtx, r.CallID) + r.Evidence = &e + err = errors.Join(err, capErr) + if r.AnsweredAt != nil && (e.FinalINVITECode < 200 || e.FinalINVITECode >= 300) { + err = errors.Join(err, errors.New("debug evidence has no matching original INVITE 2xx final response for the observed native answer")) + } + } + if restore != nil && safeToRestore { + err = errors.Join(err, restore()) + } + err = errors.Join(err, h.Close()) + r.FinishedAt = time.Now().UTC() + if err != nil { + r.Failure = err.Error() + if r.Status == "connected" { + r.Status = "failed" + } + } + data, jsonErr := json.MarshalIndent(r, "", " ") + if jsonErr == nil { + jsonErr = os.WriteFile(filepath.Join(dir, "result.json"), append(data, '\n'), 0600) + } + err = errors.Join(err, jsonErr) + }() + if err = ctx.Err(); err != nil { + return r, err + } + if err = h.CheckIdle(ctx); err != nil { + return r, fmt.Errorf("preflight: %w", err) + } + if debug { + if c.Interface == "" { + return r, errors.New("DEBUG_INTERFACE is required with --debug/-D") + } + capture, err = start(ctx, c, dir) + if err != nil { + return r, fmt.Errorf("capture preflight: %w", err) + } + } + c.resultDir = dir + restore, err = h.Apply(ctx, c) + if err != nil { + return r, fmt.Errorf("apply test line: %w", err) + } + if err = ctx.Err(); err != nil { + return r, err + } + if err = h.CheckIdle(ctx); err != nil { + return r, fmt.Errorf("pre-dial idle check: %w", err) + } + req := asterisk.NativeDial{ExecutionID: id, TrunkID: c.Endpoint(), DialedCallee: route, CallerID: c.CallerID, AnswerTimeout: c.Ring, BindSIPCall: func(_ string, callID string) error { + if callID == "" { + return errors.New("missing native SIP Call-ID") + } + if capture != nil { + if e := capture.Check(); e != nil { + return e + } + } + r.CallID = callID + now := time.Now().UTC() + r.DialBoundAt = &now + return nil + }} + r.Status = "not_connected" + call, callErr := h.Dial(ctx, req) + if callErr != nil { + var failure *asterisk.NativeCallFailure + if errors.As(callErr, &failure) && failure.ConfirmedEnd { + r.EndConfirmed = true + observed := time.Now().UTC() + r.EndedAt = &observed + } else if asterisk.NativeCallNeverSubmitted(callErr) { + r.Status = "not_dialed" + } else { + safeToRestore = false + r.Status = "unknown" + } + return r, callErr + } + now := time.Now().UTC() + r.AnsweredAt = &now + timer := time.NewTimer(c.Hold) + defer timer.Stop() + select { + case <-timer.C: + case <-call.Done().Done(): + case <-ctx.Done(): + err = ctx.Err() + } + err = errors.Join(err, call.Close()) + r.EndConfirmed = call.EndConfirmed() + if !r.EndConfirmed { + safeToRestore = false + r.Status = "unknown" + return r, errors.Join(err, errors.New("call termination unconfirmed; test configuration retained for manual diagnosis; do not retry")) + } + observed := time.Now().UTC() + r.EndedAt = &observed + r.Status = "connected" + return r, err +} + +func ensureOutput(path string) error { + if err := os.MkdirAll(path, 0700); err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 { + return errors.New("OUTPUT_DIR must be a private real directory (0700)") + } + return nil +} diff --git a/internal/sipcall/run_test.go b/internal/sipcall/run_test.go new file mode 100644 index 0000000..01d472b --- /dev/null +++ b/internal/sipcall/run_test.go @@ -0,0 +1,160 @@ +package sipcall + +import ( + "context" + "errors" + "git.ipao.vip/rogee/go-sip/internal/asterisk" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type fakeCall struct { + closeErr error + ended bool + ctx context.Context +} + +func (c *fakeCall) Close() error { return c.closeErr } +func (c *fakeCall) EndConfirmed() bool { return c.ended } +func (c *fakeCall) Done() context.Context { return c.ctx } + +type fakeHost struct { + busy, applyErr, dialErr, restoreErr error + calls, restores int + call *fakeCall +} + +func (h *fakeHost) CheckIdle(context.Context) error { return h.busy } +func (h *fakeHost) Apply(context.Context, Config) (func() error, error) { + return func() error { h.restores++; return h.restoreErr }, h.applyErr +} +func (h *fakeHost) Dial(ctx context.Context, request asterisk.NativeDial) (testCall, error) { + h.calls++ + if h.dialErr != nil { + return nil, h.dialErr + } + if err := request.BindSIPCall(request.ExecutionID, "sip-id-123"); err != nil { + return nil, err + } + if h.call == nil { + h.call = &fakeCall{ended: true, ctx: context.Background()} + } + return h.call, nil +} +func (h *fakeHost) Close() error { return nil } + +type fakeCapture struct { + stops int + err error +} + +func (c *fakeCapture) Check() error { return nil } +func (c *fakeCapture) Stop(_ context.Context, _ string) (Evidence, error) { + c.stops++ + return Evidence{PCAP: "capture.pcap", SIPTimeline: "sip.tsv", Packets: 10, FinalINVITECode: 200}, c.err +} + +func fixtureConfig(t *testing.T) Config { + t.Helper() + c, err := ParseConfig(strings.NewReader(validENV)) + if err != nil { + t.Fatal(err) + } + c.OutputDir = t.TempDir() + if err := os.Chmod(c.OutputDir, 0700); err != nil { + t.Fatal(err) + } + c.resultDir = c.OutputDir + c.Hold = time.Millisecond + return c +} + +func TestRunDefaultNeedsNoCaptureToolsAndDialsOnce(t *testing.T) { + c := fixtureConfig(t) + h := &fakeHost{} + r, err := run(context.Background(), c, "18601010101", false, h, func(context.Context, Config, string) (captureSession, error) { + t.Fatal("capture must be completely absent by default") + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if r.Status != "connected" || !r.EndConfirmed || r.CallID != "sip-id-123" || r.DialedCallee != "708918601010101" || h.calls != 1 || h.restores != 1 || r.Evidence != nil { + t.Fatalf("wrong single call: %+v", r) + } + data, err := os.ReadFile(filepath.Join(r.Directory, "result.json")) + if err != nil || !strings.Contains(string(data), "connected") { + t.Fatalf("missing private result: %v", err) + } + info, _ := os.Stat(r.Directory) + if info.Mode().Perm() != 0700 { + t.Fatal("results must be private") + } +} + +func TestRunFailuresAreVisibleAndNeverRetry(t *testing.T) { + for _, phase := range []string{"busy", "apply", "capture", "dial", "end", "restore", "decode"} { + t.Run(phase, func(t *testing.T) { + c := fixtureConfig(t) + h := &fakeHost{} + cap := &fakeCapture{} + debug := phase == "capture" || phase == "decode" + sentinel := errors.New("diagnostic failure") + switch phase { + case "busy": + h.busy = sentinel + case "apply": + h.applyErr = sentinel + case "dial": + h.dialErr = sentinel + case "end": + h.call = &fakeCall{ctx: context.Background()} + case "restore": + h.restoreErr = sentinel + case "decode": + cap.err = sentinel + } + r, err := run(context.Background(), c, "18601010101", debug, h, func(context.Context, Config, string) (captureSession, error) { + if phase == "capture" { + return nil, sentinel + } + return cap, nil + }) + if err == nil || r.Status == "connected" || h.calls > 1 { + t.Fatalf("failure disguised: %+v %v", r, err) + } + if (phase == "busy" || phase == "capture" || phase == "apply") && h.calls != 0 { + t.Fatal("preflight failure dialed") + } + if phase == "end" && h.restores != 0 { + t.Fatal("unknown live channel must retain its configuration") + } + if debug && phase == "decode" && cap.stops != 1 { + t.Fatal("capture must always be finalized") + } + if _, err = os.Stat(filepath.Join(r.Directory, "result.json")); err != nil { + t.Fatalf("failure detail missing: %v", err) + } + }) + } +} + +func TestRunDebugHasLinkedEvidenceAndCancellationDoesNotRedial(t *testing.T) { + c := fixtureConfig(t) + h := &fakeHost{} + cap := &fakeCapture{} + r, err := run(context.Background(), c, "18601010101", true, h, func(context.Context, Config, string) (captureSession, error) { return cap, nil }) + if err != nil || r.Evidence == nil || cap.stops != 1 { + t.Fatalf("evidence missing: %+v %v", r, err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + h = &fakeHost{} + _, err = run(ctx, c, "18601010101", false, h, nil) + if err == nil || h.calls != 0 { + t.Fatal("cancelled invocation must not dial") + } +} diff --git a/scripts/acceptance-local.sh b/scripts/acceptance-local.sh index 41dea4e..5677357 100755 --- a/scripts/acceptance-local.sh +++ b/scripts/acceptance-local.sh @@ -27,6 +27,7 @@ go vet ./... tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT go build -trimpath -buildvcs=false -o "$tmp/sip-go-agent" ./cmd/sip-go-agent +go build -trimpath -buildvcs=false -o "$tmp/sip-call" ./cmd/sip-call bash scripts/check-current-mq-mock.sh