Remove local outbound time and attempt caps in favor of SaaS policy

This commit is contained in:
2026-10-06 15:06:34 +08:00
parent 37e361bb0d
commit 6153becb48
17 changed files with 52 additions and 200 deletions
+4 -4
View File
@@ -95,7 +95,7 @@
- AI 使用任务内不可变授权快照:仅经获批准百炼/火山 ASR、OpenAI 兼容 LLM、百炼 TTS(`qwen3-tts-flash`/`Cherry`/`Chinese`)能表达的参数进入每通话实例;ASR-only 不启动 LLM/TTS,完整 AI 不借旧语音测试的授权或参数。只有最终用户 ASR 文本的明确字面关键词可触发拒联/挂断;不由 SDK 默认值、环境、CLI、metadata 或宽松 Schema 改写业务参数,不因 SDK 重试产生第二次发起/收费或重播。日志只存脱敏版本/摘要/计数,不存密钥、prompt、完整对话或音频。
- **私有配置位置(本机路径相对本仓库根目录,只读,绝不提交)**:`.local/provider-ai.env` 是 `0600` 的 `KEY=VALUE` 文件;字段名为 `BAILIAN_API_KEY`、`BAILIAN_BASE_URL`、`BAILIAN_WSS_BASE_URL`、`BAILIAN_TTS_VOICE`、`VOLC_ASR_APP_NAME`、`VOLC_ASR_APP_KEY`、`VOLCENGINE_ACCESS_KEY`、`VOLCENGINE_SECRET_KEY`、`VOLCENGINE_REGION`、`VOLCENGINE_DISABLE_SSL`。根目录 `aliyun-oss.env` 也是 `0600`,**不是 shell env 文件**;它以冒号分隔,字段名准确为 `bucket`、`Endpoint`、`Region`,以及 `RAM` 下的 `username`、`accessKeyId`、`accessKeySecret`(大小写须保持原样)。测试机 `rogee` 用户的现行 ARI 文件位于 `~/.config/go-sip-asterisk/{ari.conf,http.conf,ari-secret}`,不是旧 `.local/asterisk-*/ari.conf`;访问测试机前先核对已登记的 SSH 主机指纹,不展示 `ari-secret`。
- **下次安全读取步骤**:先确认工作目录是本仓库,用 `stat` 仅检查本机两份文件是否存在、所有者与权限 `0600`;不满足即停止。按各自格式在受限本机进程中解析所需字段到内存,不执行 `source`、不打印全文/字段值、不写临时明文副本,不把密钥、签名 URL、音频或完整对话带入聊天、日志、提交及长期证据。AI 的历史文件只可作为**获准凭据来源**,模型/voice/速度等仍由当前获批的 task/providers 快照固定,不能用环境变量覆盖。OSS 历史文件也不能直接传给 `DISPATCHER_OSS_CONFIG_FILE`:该运行配置要求私有 JSON、`dispatcher_id` 和 `oss` 字段,并以环境变量**名称引用**密钥;需按现行合同构造并核验授权后才能使用。普通构建和测试不读取这些私有文件;真实服务测试必须显式启用对应 opt-in 并受现行门禁约束。
- **授权边界**:本轮验收目标是三条已登记线路的真实接通及 LLM 正常应答;旧两个号码已分别在三条线路试拨,六通均为 SIP 480,零接通。新增号码的历史逐次授权不等于本次代码变更获准部署或拨号;本次仅修改并本地验证号码准入,**不部署、不拨号**。上述 AI 与 OSS 私有配置仍仅用于另经明确授权的非生产测试,下次任务须重新确认范围和真实服务调用授权,不能沿用本轮或历史一次性授权。不得把历史配置直接当 SaaS 快照、任务授权或真实呼叫准入,不覆盖/清理旧 OSS 对象。
- **授权边界**:本轮验收目标是三条已登记线路的真实接通及 LLM 正常应答;旧两个号码已分别在三条线路试拨,六通均为 SIP 480,零接通。新增号码的历史逐次授权不等于本次代码变更获准部署或拨号;本次全局审查本地业务硬编码并以 SaaS 配置快照决定任务、线路和额度,**不部署、不拨号**。上述 AI 与 OSS 私有配置仍仅用于另经明确授权的非生产测试,下次任务须重新确认范围和真实服务调用授权,不能沿用本轮或历史一次性授权。不得把历史配置直接当 SaaS 快照、任务授权或真实呼叫准入,不覆盖/清理旧 OSS 对象。
- Agent 录音经受控双向 TLS 向 D 领取短期 OSS 上传授权,每次尝试只作**一次 HTTPS PUT**;正常上传不写录音文件,最终结果在 Dispatcher 确认前允许写入 Agent 私有临时结果文件,确认后删除;已确认挂断但结束回报未确认时须保留原结果并重报原结束事实;PUT 前预存的结果在成功未被确认时不得自行报告。首次明确失败须先完整保存录音与结果两份恢复文件,才从该时刻启动 48 小时重试;按 1、2、4、8、16、32、60 分钟及其后每 60 分钟的固定节奏显式重新申请授权,同一 OSS 目标、同一消息身份。PUT 结果未知不得盲目重传;48 小时届满仍失败时保留文件待人工,**不伪造最终结果或自动清理**。D 不转发文件,已确认结束的通话及时释放执行占用;未知执行仍占用。只有真实终结后才通过唯一 `call.execute.result` 回报录音路径、最终转写和拒联事实;无录音或录音生成失败以空 `recording={}` 和真实结果收口,生成失败须说明原因。不能恢复的录音不声称零丢失,也不伪造 OSS/SaaS 应用回执。凭据/TOKEN/签名 URL 不写入样例、日志、源码或证据。
## SIP 与真实呼叫限制
@@ -106,8 +106,8 @@
| 中鼎 | `60.171.24.90:5060` | `mbkq` | 无 |
| 百应 | `160.202.254.79:5060` | `KQ91526` | `mka755` |
- 全线路的原始被叫号码仅由校验归属及任务后的 SaaS `call.execute.payload.callee` 确定;不在 Dispatcher、SaaS Mock 或抓证脚本设置固定号码/日期特判,不向任务快照增设号码列表。只接受 1–32 位 ASCII 数字;格式校验不等于真实拨号授权。非生产真实呼叫仍仅在 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含)放行,每条 trunk 对每个原始号码每天最多 3 次,窗口外直接拒绝,不等候/自动延迟/自动重试/静默换线。每次真实试拨仍须使用者明确安排,并由专用主机脚本在拨号前启用抓包和 PJSIP logger、签发与该通 `event_id`/trunk/原始号码绑定的短时有效活跃抓包凭证;Agent 拒绝缺失/失效/不匹配的凭证。不能拿 Mock 时段测试宣称真实放行。
- 任务按周一至周日多个时段与指定排除日期配置,线路只有每周允许时段(**没有线路排除日期**),Asia/Shanghai 左闭右开、跨日拆分;缺失或不确定 fail-closed,不自动重拨。由 Dispatcher 在持久接纳与实际发出指令前判定,并取任务/获批 AI 较小通话时限;Agent 仅校验会话和签发期限,不重算外呼策略。本地策略 Mock 与固定真实门禁必须分别报告。
- 全线路的原始被叫号码仅由校验归属及任务后的 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 投递和本地时段测试不构成真实拨号授权。
- 任务按周一至周日多个时段与指定排除日期配置,线路只有每周允许时段(**没有线路排除日期**),Asia/Shanghai 左闭右开、跨日拆分;缺失或不确定 fail-closed,不自动重拨。由 Dispatcher 在持久接纳与实际发出指令前判定,并取任务/获批 AI 较小通话时限;Agent 仅校验会话和签发期限,不重算外呼策略。本地 SaaS 快照策略测试与主机抓证/逐次授权须分别报告。
- `BD` 等主叫原值不得清洗或当作 Digest 用户名;业务原始被叫号码不变,仅被选定数企 trunk 按规则构造 `7089<原号>`(其它线路使用自己的前缀),不重复加前缀。三条 trunk 独立,不能把同地址伪造为备用线路或换线重拨;服务商反馈 PCMA,对应 Asterisk `allow=alaw`,传输/注册/鉴权/并发仍待真实签收。不以 sipgo/diago 另造 Asterisk 替代架构。
## 运行环境、诊断与开发门禁
@@ -115,6 +115,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 呼叫必须先通过上述真实时间门禁;**拨号前**启动受限 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 下发的任务、线路时段与额度校验及逐次授权;**拨号前**启动受限 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` **不能代签主机诊断或生产门禁**。
- 当前完成前至少检查格式、当前合同和 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 目标。问题根因不明时补可观测性并诚实报告,不能用静默兜底伪装修复。
+2 -2
View File
@@ -2,7 +2,7 @@
独立 Go SIP 调度与执行项目。单一 Go 1.27.1 module/制品通过 Cobra 显式提供 `dispatcher`、`agent` 两个业务子命令;当前业务启动只允许**隔离 Mock**,mixed/real 直接拒绝。目标是分阶段以 Go 替换 Agent,不重写 Asterisk,也不建立第二套 SaaS 管理后台。项目的源码、文档、依赖、构建、测试、配置和发布入口均在本仓库内。
> **范围与进度:** [唯一现行规范](docs/thirds/saas-dispatcher.md)和 [P01–P08 本地验收对照](docs/evidence/saas-dispatcher-p08-acceptance.md)分别说明当前合同及已通过的隔离测试(手写业务覆盖率 72.0%)。隔离 RabbitMQ、双向 TLS/HTTPS、OSS PUT 与 AI Mock 的通过,不代表真实 SaaS、管理平台、MQ 应用收讫、OSS/AI 供应商、Asterisk/SIP/ECS、真实拨号或生产切换已验证;当前发布清单 `production_approval=false`。真实试拨另需逐次授权,并遵守白名单、Asia/Shanghai `09:00`–`20:00` 及拨号前诊断抓包门禁。
> **范围与进度:** [唯一现行规范](docs/thirds/saas-dispatcher.md)和 [P01–P08 本地验收对照](docs/evidence/saas-dispatcher-p08-acceptance.md)分别说明当前合同及已通过的隔离测试(手写业务覆盖率 69.6%)。隔离 RabbitMQ、双向 TLS/HTTPS、OSS PUT 与 AI Mock 的通过,不代表真实 SaaS、管理平台、MQ 应用收讫、OSS/AI 供应商、Asterisk/SIP/ECS、真实拨号或生产切换已验证;当前发布清单 `production_approval=false`。真实试拨另需逐次授权,按 SaaS 任务/线路快照校验时段与额度,并遵守拨号前诊断抓包门禁;本地不另设固定号码、时间或每日次数限制。
## 唯一当前接口
@@ -18,7 +18,7 @@ make check # 格式、Proto、当前合同/历史来源、race、
make release-check-local # 本地制品、hash、当前拓扑、Mock-only/不覆盖既有文件的包检查
```
结果、重启与故障场景及未验证项见 [`docs/evidence/saas-dispatcher-implementation.md`](docs/evidence/saas-dispatcher-implementation.md)。本地检查不等于非生产主机验收:任何实际新主机/版本/Cell 验证须执行 [`deploys/test/nonprod-call-evidence.sh`](deploys/test/nonprod-call-evidence.sh) 规定的资源、Asterisk/systemd、ARI/PJSIP 与媒体诊断;mixed/real 外呼还须在拨号**之前**启动受限 SIP/RTP 抓包和 PJSIP logger。当前 Mock-only 制品不能启用 mixed/real,更不能因白名单和样例自动发起真实呼叫。
结果、重启与故障场景及未验证项见 [`docs/evidence/saas-dispatcher-implementation.md`](docs/evidence/saas-dispatcher-implementation.md)。本地检查不等于非生产主机验收:任何实际新主机/版本/Cell 验证须执行 [`deploys/test/nonprod-call-evidence.sh`](deploys/test/nonprod-call-evidence.sh) 规定的资源、Asterisk/systemd、ARI/PJSIP 与媒体诊断;mixed/real 外呼还须在拨号**之前**启动受限 SIP/RTP 抓包和 PJSIP logger。当前 Mock-only 制品不能启用 mixed/real,更不能因 SaaS 消息和样例自动发起真实呼叫。
## 导航
+1 -1
View File
@@ -4,7 +4,7 @@
"sources": {
"docs/archive/sources/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada",
"docs/archive/sources/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87",
"docs/thirds/saas-dispatcher.md": "f8e7f52c05ed83e47b600331e3f8ac6338d94c00d75688ab9e1dedf57b2f41ae"
"docs/thirds/saas-dispatcher.md": "e9e780a75c203ecfc36e43db93bf22a12b52c4c337bd6d2dd410e105ca7326f9"
},
"bundle_sha256": "e5ac2b46cb6544778774cce4616ae0d9b6da941206805f81e1a4a6aaa3e3a3d5",
"bundle_algorithm": "sha256 of sorted relative-path + space + sha256(file) + newline; only root-level JSON and examples/**/*.json, excluding manifest.json"
+5 -4
View File
@@ -51,8 +51,9 @@ 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. Real SIP outbound calls are permitted only from 09:00
(inclusive) through 20:00 (exclusive), Asia/Shanghai time; outside that window
the Agent/Dispatcher must fail closed rather than wait, retry, delay or switch
trunks. A prior confirmation does not authorize retries or additional targets;
number and capture plan. 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
authorize retries or additional targets;
failed calls must stop for a new confirmation.
+3 -3
View File
@@ -22,9 +22,9 @@ 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
call authorization and the approved target/trunk. It refuses production mode
and fails closed when its prerequisites are missing. Before any dial attempt it
checks the Asia/Shanghai 09:00–20:00 window twice (09:00 included, 20:00
excluded), the exact `enabled` + `active` Asterisk systemd state, the running
ARI module and HTTP `/ari/` route, the selected PJSIP endpoint, and SHA-256
relies on Dispatcher validation of SaaS task/trunk schedules and quotas,
without a separate local fixed-hour or daily-attempt limit. It checks the
exact `enabled` + `active` Asterisk systemd state, the running ARI module and HTTP `/ari/` route, the selected PJSIP endpoint, and SHA-256
of the installed binary and configuration. Missing facts fail the validation;
`--preflight-only` never authorizes a call. After capture, missing capture or
recording SHA-256, Asterisk journal, SIP summary, logger shutdown, timestamp, or
+4 -63
View File
@@ -17,7 +17,6 @@ Options:
--rtp-start PORT RTP range start (default: 10000).
--rtp-end PORT RTP range end (default: 10800).
--preflight-only Start/stop capture and diagnostics without a call; do not require packets.
--attempt-ledger FILE Daily trunk/number attempt ledger (default: /var/lib/sip-go-agent/state/real-call-attempts.tsv).
--proof-root DIR Live capture arm directory (default: /run/sip-go-agent/nonprod-armed).
EOF
exit 2
@@ -39,11 +38,9 @@ trunk=""
target=""
call_command=()
preflight_only=0
attempt_ledger="/var/lib/sip-go-agent/state/real-call-attempts.tsv"
proof_root="/run/sip-go-agent/nonprod-armed"
proof_file=""
proof_created=0
attempt_number=0
while (($#)); do
case "$1" in
@@ -58,7 +55,6 @@ while (($#)); do
--rtp-start) [[ $# -ge 2 ]] || usage; rtp_start=$2; shift 2 ;;
--rtp-end) [[ $# -ge 2 ]] || usage; rtp_end=$2; shift 2 ;;
--preflight-only) preflight_only=1; shift ;;
--attempt-ledger) [[ $# -ge 2 ]] || usage; attempt_ledger=$2; shift 2 ;;
--proof-root) [[ $# -ge 2 ]] || usage; proof_root=$2; shift 2 ;;
--trunk) [[ $# -ge 2 ]] || usage; trunk=$2; shift 2 ;;
--target) [[ $# -ge 2 ]] || usage; target=$2; shift 2 ;;
@@ -75,24 +71,12 @@ case "$environment" in
esac
[[ "$asterisk_scope" == system || "$asterisk_scope" == user ]] || { echo 'invalid Asterisk service scope' >&2; exit 1; }
[[ "$call_id" =~ ^[A-Za-z0-9._-]+$ ]] || { echo 'invalid call id' >&2; exit 1; }
[[ "$trunk" =~ ^(provider-primary|provider-second|provider-third|trunk-[A-Za-z0-9._-]+)$ ]] || { echo 'trunk is not an approved non-production trunk id' >&2; exit 1; }
[[ "$trunk" =~ ^[A-Za-z0-9._-]{1,128}$ ]] || { echo 'invalid SaaS trunk identifier' >&2; exit 1; }
[[ "$target" =~ ^[0-9]{1,32}$ ]] || { echo 'SaaS event target is not an original numeric dial route' >&2; exit 1; }
[[ "$attempt_ledger" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid attempt ledger path' >&2; exit 1; }
[[ "$proof_root" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid proof root path' >&2; exit 1; }
[[ ${#call_command[@]} -gt 0 ]] || { echo 'call command is required after --' >&2; exit 1; }
[[ "$interface" =~ ^[A-Za-z0-9_.:-]+$ ]] || { echo 'invalid capture interface' >&2; exit 1; }
[[ "$sip_port" =~ ^[0-9]+$ && "$rtp_start" =~ ^[0-9]+$ && "$rtp_end" =~ ^[0-9]+$ ]] || { echo 'invalid port' >&2; exit 1; }
require_call_window() {
local shanghai_hm
shanghai_hm="$(TZ=Asia/Shanghai date +%H%M)" || { echo 'Asia/Shanghai clock unavailable; fail-closed' >&2; exit 1; }
if [[ ! "$shanghai_hm" =~ ^[0-9]{4}$ || "$shanghai_hm" < "0900" || "$shanghai_hm" > "1959" ]]; then
echo 'outside Asia/Shanghai 09:00-20:00; fail-closed' >&2
exit 1
fi
}
# A diagnostic that exits before the call command can run off-hours; every
# real attempt still checks the time gate here and again immediately pre-dial.
if (( ! preflight_only )); then require_call_window; fi
# "any" includes both provider SIP and the local Asterisk ExternalMedia RTP.
# Reducing it to the default-route NIC silently omits loopback media.
if [[ -z "$evidence_dir" ]]; then
@@ -115,7 +99,6 @@ tcpdump_bin="${TCPDUMP_BIN:-$(command -v tcpdump || true)}"
[[ -x "$asterisk_bin" ]] || { echo 'Asterisk CLI unavailable; fail-closed'; exit 1; }
[[ -n "$tcpdump_bin" && -x "$tcpdump_bin" ]] || { echo 'tcpdump unavailable; fail-closed'; exit 1; }
command -v runuser >/dev/null || { echo 'runuser unavailable; fail-closed'; exit 1; }
command -v flock >/dev/null || { echo 'flock unavailable for daily attempt gate; fail-closed'; exit 1; }
command -v python3 >/dev/null || { echo 'python3 unavailable for SIP evidence summary; fail-closed'; exit 1; }
# A successful one-packet probe or a timeout after opening the capture proves
@@ -128,8 +111,8 @@ if [[ "$probe_status" != 0 && "$probe_status" != 124 ]]; then
fi
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","attempt_ledger":"%s","attempt_number":%s,"sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \
"$environment" "$call_id" "$trunk" "$target" "$interface" "$attempt_ledger" "$attempt_number" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json"
printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \
"$environment" "$call_id" "$trunk" "$target" "$interface" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json"
redact() {
sed -E 's/(password|secret|token|authorization|api[_-]?key)[^[:space:]]*/\1=<redacted>/Ig'
@@ -345,47 +328,6 @@ cleanup() {
exit "$call_exit"
}
trap cleanup EXIT
reserve_attempt() {
if ((preflight_only)); then
return
fi
local today count legacy_count metadata
today="$(TZ=Asia/Shanghai date +%F)"
install -d -m 0700 "$(dirname "$attempt_ledger")"
touch "$attempt_ledger"
exec 9>>"$attempt_ledger.lock"
flock -x 9
count="$(awk -F '\t' -v d="$today" -v t="$trunk" -v n="$target" '$1 == d && $2 == t && $3 == n {count++} END {print count + 0}' "$attempt_ledger")"
legacy_count=0
while IFS= read -r metadata; do
if grep -q '"environment":"development"' "$metadata" \
&& grep -q '"call_id":"real-' "$metadata" \
&& grep -q "\\\"trunk\\\":\\\"$trunk\\\"" "$metadata" \
&& grep -q "\\\"target\\\":\\\"$target\\\"" "$metadata" \
&& grep -q "\\\"started_at\\\":\\\"$today" "$metadata"; then
legacy_count=$((legacy_count + 1))
fi
done < <(find /var/lib/sip-go-agent/evidence -mindepth 2 -maxdepth 2 -type f -name metadata.json -print 2>/dev/null)
if ((legacy_count > count)); then
count=$legacy_count
fi
if ((count >= 3)); then
printf 'attempt_rejected=quota\ndate=%s\ntrunk=%s\ntarget=%s\nknown_attempts=%s\nmax_attempts=3\n' \
"$today" "$trunk" "$target" "$count" >"$evidence_dir/attempt-rejected.txt"
flock -u 9
exec 9>&-
echo "daily SIP/number attempt limit reached: $trunk/$target has $count attempts on $today" >&2
exit 1
fi
attempt_number=$((count + 1))
printf '%s\t%s\t%s\t%s\t%s\n' "$today" "$trunk" "$target" "$call_id" "$started_at" >>"$attempt_ledger"
flock -u 9
exec 9>&-
printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","attempt_ledger":"%s","attempt_number":%s,"sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \
"$environment" "$call_id" "$trunk" "$target" "$interface" "$attempt_ledger" "$attempt_number" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json"
}
reserve_attempt
asterisk_cli "pjsip set logger on" >"$evidence_dir/pjsip-logger-on.txt" 2>&1 || { echo 'cannot enable PJSIP logger; fail-closed' >&2; exit 1; }
logger_enabled=1
@@ -402,7 +344,6 @@ if ((preflight_only)); then
exit 0
fi
require_call_window
# The Agent checks this root-owned, call-specific live capture arm before any
# originate. A stale arm is never overwritten; the trap removes it first.
install -d -o root -g "$run_as" -m 0750 -- "$proof_root"
@@ -428,6 +369,6 @@ capture_packets="$(awk '/ packets captured/{print $1; exit}' "$evidence_dir/tcpd
[[ "$capture_packets" =~ ^[0-9]+$ ]] || capture_packets=0
capture_status=0
if ((capture_packets == 0)); then capture_status=2; fi
printf 'call_exit=%s\ncapture_packets=%s\ncapture_status=%s\nattempt_number=%s\n' "$call_status" "$capture_packets" "$capture_status" "$attempt_number" >"$evidence_dir/result.txt"
printf 'call_exit=%s\ncapture_packets=%s\ncapture_status=%s\n' "$call_status" "$capture_packets" "$capture_status" >"$evidence_dir/result.txt"
if ((call_status != 0)); then exit "$call_status"; fi
exit "$capture_status"
+2 -2
View File
@@ -4,7 +4,7 @@
## 数据
准备仅自己可读的目录,包含 `sip.json`、`providers.json`、`quota.json` 和 `tasks/*.json`;所有 JSON 文件须为普通文件且权限为 `0600`。分别对应 [`contracts/local/`](../../../contracts/local/) 的 `sip_config`、`ai_providers`、`tenant_quota`、`task_config`;每份快照的 `dispatcher_id` 必须相同,任务须属于同一租户且文件名为 `<task_id>.json`。本阶段最多六项任务,启动时全部校验并读入内存;更改文件后须重新启动,不热替换在途任务。现有 `contracts/local/examples/` **仅用于隔离 Mock 测试**,不得直接复制成真实拨号授权。
准备仅自己可读的目录,包含 `sip.json`、`providers.json`、`quota.json` 和 `tasks/*.json`;所有 JSON 文件须为普通文件且权限为 `0600`。分别对应 [`contracts/local/`](../../../contracts/local/) 的 `sip_config`、`ai_providers`、`tenant_quota`、`task_config`;每份快照的 `dispatcher_id` 必须相同,任务须属于同一租户且文件名为 `<task_id>.json`。启动时全部校验并读入内存;更改文件后须重新启动,不热替换在途任务。现有 `contracts/local/examples/` **仅用于隔离 Mock 测试**,不得直接复制成真实拨号授权。
## 运行
@@ -13,7 +13,7 @@
- 准备测试 HTTPS 证书与私钥;将 `SAAS_MOCK_DISPATCHER_SECRET` 和含凭据的 `SAAS_MOCK_RABBITMQ_URL` 放在受限环境文件,不在命令行、仓库或聊天中传输。
- 启动:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id <UUID> --listen <地址:端口> --tls-cert <证书文件> --tls-key <私钥文件>`。
- 服务以标准 `X-DISPATCHER-id` 和 `X-DISPATCHER-SECRET-KEY` 校验 Dispatcher,再提供五类只读配置。错误的归属、资源、租户、快照或消息队列会导致拒绝启动/读取。
- 单次投递另起命令:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id <UUID> --publish-event-id <唯一事件号> --publish-task-id <单线路任务号> --publish-callee <SaaS事件原始数字号码> --await-result-file <私有结果文件>`。此命令在专用结果队列中等待精确匹配的单通最终结果,先将原始结果写入 `0600` 私有文件并同步磁盘,才确认 MQ 消费;标准输出只显示结果摘要/hash,不输出转写、录音或签名 URL。必须在 `nonprod-call-evidence.sh --call-id <同一事件号> --trunk <任务唯一线路> --target <同一原始号码> -- <单次投递命令>` 启用并确认 SIP/RTP 抓包、PJSIP logger 和主机门禁之后运行;不得预投、批量投递、自动重试或换线。任务快照须只允许一条真实线路,投递仅含任务号和原始号码。RabbitMQ 必须用上述专用环境变量,不能借用默认或共享 vhost。`call.execute` 的 `dispatched` 只是派发回执:归属匹配时先私密保存为 `<结果文件>.receipt.json` 并确认,再继续等待唯一最终结果并保持抓包。明确未拨号的 `rejected` 回执则先私密保存为 `<结果文件>.rejected.json` 再确认,并立即按无呼叫失败结束等待;不伪造最终通话结果。不匹配的消息不确认。发布确认只代表 MQ 接收,不代表 SaaS 已收到最终结果;结果等待超时/归属不符时不清理未知通话,也不重发同通命令。已有未交付队列消息须人工确认处置,不自动清理。
- 单次投递另起命令:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id <UUID> --publish-event-id <唯一事件号> --publish-task-id <单线路任务号> --publish-callee <SaaS事件原始数字号码> --await-result-file <私有结果文件>`。此命令在专用结果队列中等待精确匹配的单通最终结果,先将原始结果写入 `0600` 私有文件并同步磁盘,才确认 MQ 消费;标准输出只显示结果摘要/hash,不输出转写、录音或签名 URL。必须在 `nonprod-call-evidence.sh --call-id <同一事件号> --trunk <任务唯一线路> --target <同一原始号码> -- <单次投递命令>` 启用并确认 SIP/RTP 抓包、PJSIP logger 和主机门禁之后运行;不得预投、批量投递、自动重试或换线。本单次抓证工具须预先绑定任务的唯一允许线路,避免 Dispatcher 动态选线与抓包凭证不匹配;正式 Dispatcher 的 SaaS 任务快照仍可列多条允许线路。投递仅含任务号和原始号码。RabbitMQ 必须用上述专用环境变量,不能借用默认或共享 vhost。`call.execute` 的 `dispatched` 只是派发回执:归属匹配时先私密保存为 `<结果文件>.receipt.json` 并确认,再继续等待唯一最终结果并保持抓包。明确未拨号的 `rejected` 回执则先私密保存为 `<结果文件>.rejected.json` 再确认,并立即按无呼叫失败结束等待;不伪造最终通话结果。不匹配的消息不确认。发布确认只代表 MQ 接收,不代表 SaaS 已收到最终结果;结果等待超时/归属不符时不清理未知通话,也不重发同通命令。已有未交付队列消息须人工确认处置,不自动清理。
测试:`go test ./deploys/test/saas-mock` 验证正式配置客户端;设置指向**单独隔离 vhost** 的 `SAAS_MOCK_TEST_BROKER_URL` 后,`TestSaaSMockProvisionsDispatcherTopology` 还将实际预建 MQ 并用 Dispatcher 被动读回。缺省测试不会连接共享 RabbitMQ。
+4 -4
View File
@@ -93,13 +93,13 @@ func TestSaaSMockServesFormalReadContract(t *testing.T) {
}
}
func TestSaaSMockDiscoversSixDistinctTasks(t *testing.T) {
func TestSaaSMockDiscoversMoreThanSixDistinctTasks(t *testing.T) {
root := testDataDir(t)
original, err := os.ReadFile(filepath.Join(root, "tasks", "task-full.json"))
if err != nil {
t.Fatal(err)
}
for n := 2; n <= 6; n++ {
for n := 2; n <= 7; n++ {
var task map[string]any
if err := json.Unmarshal(original, &task); err != nil {
t.Fatal(err)
@@ -125,8 +125,8 @@ func TestSaaSMockDiscoversSixDistinctTasks(t *testing.T) {
t.Fatal(err)
}
tasks, cursor, err := client.ReadAllTasks(context.Background())
if err != nil || len(tasks) != 6 || cursor != "mock-complete" {
t.Fatalf("six independent formal tasks were not discovered: count=%d cursor=%q err=%v", len(tasks), cursor, err)
if err != nil || len(tasks) != 7 || cursor != "mock-complete" {
t.Fatalf("seven independent formal tasks were not discovered: count=%d cursor=%q err=%v", len(tasks), cursor, err)
}
}
+3 -8
View File
@@ -20,20 +20,15 @@ var mockCallee = regexp.MustCompile(`^[0-9]{1,32}$`)
// buildExecute deliberately carries only the two approved call inputs. Trunk,
// caller, AI and duration remain immutable properties of the SaaS task read.
func buildExecute(data dataset, eventID, taskID, callee string, now time.Time) (string, []byte, error) {
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return "", nil, err
}
hour := now.In(shanghai).Hour()
if !mockCallID.MatchString(eventID) || !mockCallID.MatchString(taskID) ||
!mockCallee.MatchString(callee) || hour < 9 || hour >= 20 {
return "", nil, errors.New("one-shot command identity, numeric callee or real call window rejected")
if !mockCallID.MatchString(eventID) || !mockCallID.MatchString(taskID) || !mockCallee.MatchString(callee) {
return "", nil, errors.New("one-shot command identity or numeric callee rejected")
}
body, ok := data.tasks[taskID]
if !ok {
return "", nil, errors.New("one-shot command task is absent from the approved SaaS dataset")
}
var task configread.Task
// The one-shot host capture arm is bound to one exact trunk before the MQ event is sent.
if err := json.Unmarshal(body, &task); err != nil || task.DispatcherID != data.dispatcherID || task.TenantID != data.tenantID || task.TaskID != taskID || task.Status != "running" || len(task.AllowedTrunkIDs) != 1 {
return "", nil, errors.New("one-shot command requires a running task pinned to exactly one approved trunk")
}
+7 -7
View File
@@ -333,10 +333,12 @@ func TestSaaSMockBuildsOneApprovedCommandForTheBoundTask(t *testing.T) {
if err := json.Unmarshal(body, &event); err != nil || event.EventID != "event-once-1" || event.Type != "call.execute" || event.DispatcherID != testDispatcher || event.TenantID != 1001 || event.Payload.TaskID != "task-full" || event.Payload.Callee != "15003164745" || strings.Contains(string(body), "trunk-mock") {
t.Fatalf("SaaS must not leak a trunk/caller/AI override into the execute command: %+v err=%v", event, err)
}
for _, number := range []string{"15803300952", "13900000000"} {
_, eventBody, err := buildExecute(data, "saas-event-"+number, "task-full", number, inside)
if err != nil || contract.ValidateCurrent("mq", eventBody) != nil || !strings.Contains(string(eventBody), `"callee":"`+number+`"`) {
t.Fatalf("valid SaaS number was changed or rejected: %q err=%v", number, err)
for _, at := range []time.Time{inside.Add(-2 * time.Hour), inside, inside.Add(10 * time.Hour)} {
for _, number := range []string{"15803300952", "13900000000"} {
_, eventBody, err := buildExecute(data, "saas-event-"+number, "task-full", number, at)
if err != nil || contract.ValidateCurrent("mq", eventBody) != nil || !strings.Contains(string(eventBody), `"callee":"`+number+`"`) {
t.Fatalf("valid SaaS task command was changed or rejected: %q at=%v err=%v", number, at, err)
}
}
}
for _, test := range []struct {
@@ -347,11 +349,9 @@ func TestSaaSMockBuildsOneApprovedCommandForTheBoundTask(t *testing.T) {
{"event-2", "missing", "15003164745", inside},
{"event-3", "task-full", "abc", inside},
{"event-4", "task-full", strings.Repeat("1", 33), inside},
{"event-5", "task-full", "15003164745", inside.Add(-2 * time.Hour)},
{"event-6", "task-full", "15003164745", inside.Add(10 * time.Hour)},
} {
if _, _, err := buildExecute(data, test.id, test.task, test.callee, test.at); err == nil {
t.Fatalf("invalid or out-of-window command was allowed: event=%q task=%q", test.id, test.task)
t.Fatalf("invalid command was allowed: event=%q task=%q", test.id, test.task)
}
}
}
+2 -2
View File
@@ -73,8 +73,8 @@ func loadDataset(dir, dispatcherID string) (dataset, error) {
}
data.tenantID = quota.TenantID
files, err := filepath.Glob(filepath.Join(dir, "tasks", "*.json"))
if err != nil || len(files) == 0 || len(files) > 6 {
return dataset{}, errors.New("SaaS test dataset must contain one to six task snapshots")
if err != nil || len(files) == 0 {
return dataset{}, errors.New("SaaS test dataset must contain at least one task snapshot")
}
for _, path := range files {
body, err := read(filepath.Join("tasks", filepath.Base(path)), "task_config")
+1 -1
View File
@@ -34,7 +34,7 @@ RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D
## 调度、AI 与真实结果(K01–K09、K11–K14)
- 当前 TTS 唯一获批适配器是 `bailian_tts`:任务快照须明确提供 `qwen3-tts-flash`、`Cherry`、`Chinese`、速度 `1` 和单声道 16 kHz PCM16 目标格式;使用已核验的 provider 凭据及生成端点。每段仅发起一次生成请求,下载返回的短期音频引用后转换为电话可用的 PCM16;不可用、超时、缺少转换工具或参数不支持时显式失败,不回退旧火山 TTS、不隐式重试或记录签名音频 URL。历史测试凭据不是任务授权,本地转换 Mock 不构成真实百炼/通话验收。
- 被叫号码只来自归属当前 D、租户及已接纳任务的 `call.execute.payload.callee` 原值;不在 Dispatcher、SaaS Mock 或抓证脚本另设固定号码列表,也不在任务快照增加号码列表。Dispatcher 与非生产发布/抓证入口只接受 1–32 位 ASCII 数字的原始号码,不能把线路前缀当成该号码的本地替代值。已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。本规则**不**放宽真实路径 Asia/Shanghai `09:00`–`20:00` 固定门禁、每线路每原始号码每日 3 次及逐通抓证门禁;格式有效或本地 Mock 收件均不是一次真实拨号的授权。
- 被叫号码只来自归属当前 D、租户及已接纳任务的 `call.execute.payload.callee` 原值;不在 Dispatcher、SaaS Mock 或抓证脚本另设固定号码列表,也不在任务快照增加号码列表。Dispatcher 与非生产发布/抓证入口只接受 1–32 位 ASCII 数字的原始号码,不能把线路前缀当成该号码的本地替代值。已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。非生产真实路径不另设固定的 `09:00`–`20:00` 时间门禁或每线路每原始号码每日 3 次上限;任务/线路时段与额度以 SaaS 已校验快照为准。逐通抓证、Agent 活跃凭证及使用者逐次授权仍须满足;格式有效或本地 Mock 收件均不构成真实拨号授权。
- 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。`call.execute.result.payload` 的 `status_line`、`raw` 与 `sip_capture_error` 始终存在:仅将经同一 ARI 通道拨号前取得的 SIP Call-ID 与 HEP INVITE 事务严格关联的最终响应写入原样状态行、完整原样报文与状态码;`raw` 不拼装、不截断,不能从目标号码、时间、挂断原因或 ARI HTTP 状态猜测。无 SIP 响应时前两项为 `null`;若已发起 SIP 但镜像/关联/解码失败,第三项须写明确错误,已确认结束仍报告真实结果并释放额度,不以原文缺失伪装为通话未知。原始报文只进入受控结果通道,不写日志、仓库或长期测试证据。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。
- 只有**用户侧 ASR 最终识别文本**包含任一 `hangup_keywords` 字面字符串才挂断;中间识别、助手回复、开场白、TTS 均不能触发;重复结果不可反复终结。同一任务 revision 不同内容拒绝准入;provider 禁用/角色不符不可调用。Mock 参数验证不等于真实供应商验收。
-33
View File
@@ -1,33 +0,0 @@
// Package callwindow enforces the fixed legal window for SIP outbound dialing.
package callwindow
import (
"fmt"
"time"
)
const (
LocationName = "Asia/Shanghai"
OpenHour = 9
CloseHour = 20
)
var shanghai = time.FixedZone(LocationName, 8*60*60)
// Allowed reports whether SIP outbound dialing is permitted at now. The
// boundary is [09:00, 20:00) in Asia/Shanghai; the input's instant, not its
// presentation timezone, is authoritative.
func Allowed(now time.Time) bool {
local := now.In(shanghai)
minutes := local.Hour()*60 + local.Minute()
return minutes >= OpenHour*60 && minutes < CloseHour*60
}
// Check returns a stable, actionable error when outbound dialing is closed.
func Check(now time.Time) error {
local := now.In(shanghai)
if Allowed(now) {
return nil
}
return fmt.Errorf("SIP outbound dialing is closed at %s; allowed window is %02d:00-%02d:00 %s", local.Format("2006-01-02 15:04:05 -0700"), OpenHour, CloseHour, LocationName)
}
-49
View File
@@ -1,49 +0,0 @@
package callwindow
import (
"strings"
"testing"
"time"
)
func TestAllowedBoundariesInShanghai(t *testing.T) {
location := time.FixedZone("test", 8*60*60)
tests := []struct {
name string
at time.Time
want bool
}{
{name: "before opening", at: time.Date(2026, 9, 20, 8, 59, 59, 0, location), want: false},
{name: "opening", at: time.Date(2026, 9, 20, 9, 0, 0, 0, location), want: true},
{name: "before closing", at: time.Date(2026, 9, 20, 19, 59, 59, 0, location), want: true},
{name: "closing", at: time.Date(2026, 9, 20, 20, 0, 0, 0, location), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Allowed(tt.at); got != tt.want {
t.Fatalf("Allowed(%s)=%v, want %v", tt.at, got, tt.want)
}
})
}
}
func TestAllowedConvertsUTCToShanghai(t *testing.T) {
if !Allowed(time.Date(2026, 9, 20, 1, 0, 0, 0, time.UTC)) {
t.Fatal("01:00 UTC should be 09:00 Asia/Shanghai and allowed")
}
if Allowed(time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)) {
t.Fatal("12:00 UTC should be 20:00 Asia/Shanghai and rejected")
}
}
func TestCheckExplainsClosedWindow(t *testing.T) {
err := Check(time.Date(2026, 9, 20, 20, 0, 0, 0, time.FixedZone("test", 8*60*60)))
if err == nil {
t.Fatal("expected closed-window error")
}
for _, want := range []string{"09:00", "20:00", "Asia/Shanghai"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q does not contain %q", err, want)
}
}
}
+4 -4
View File
@@ -16,9 +16,9 @@ func TestNonprodCallEvidenceFailsBeforeDialWithoutRequiredGates(t *testing.T) {
name, hour, environment, enabled, active, ari, endpoint, want string
beforeEvidence bool
}{
{name: "before real window", hour: "0859", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "outside Asia/Shanghai 09:00-20:00", beforeEvidence: true},
{name: "at real window end", hour: "2000", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "outside Asia/Shanghai 09:00-20:00", beforeEvidence: true},
{name: "invalid local clock", hour: "error", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "Asia/Shanghai clock unavailable", beforeEvidence: true},
{name: "before previous fixed window", hour: "0859", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"},
{name: "at previous fixed window end", hour: "2000", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"},
{name: "local clock unavailable", hour: "error", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"},
{name: "window opens at nine", hour: "0900", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"},
{name: "last permitted minute", hour: "1959", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"},
{name: "invalid nonproduction environment", hour: "1000", environment: "Production", enabled: "enabled", active: "active", ari: "ready", want: "invalid non-production environment", beforeEvidence: true},
@@ -59,7 +59,7 @@ func TestNonprodCallEvidenceFailsBeforeDialWithoutRequiredGates(t *testing.T) {
command := exec.CommandContext(ctx, "bash", "../../deploys/test/nonprod-call-evidence.sh",
"--environment", tc.environment, "--trunk", "provider-primary", "--target", "15003164745",
"--interface", "lo", "--run-as", currentUser.Username, "--recording-dir", filepath.Join(tools, "recordings"),
"--evidence-dir", evidence, "--attempt-ledger", filepath.Join(tools, "attempts.tsv"), "--", "/bin/true")
"--evidence-dir", evidence, "--", "/bin/true")
command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN="+asterisk, "TCPDUMP_BIN="+tcpdump,
"TEST_DIAL_MARKER="+marker, "TEST_ENABLED="+tc.enabled, "TEST_ACTIVE="+tc.active, "TEST_ARI="+tc.ari, "TEST_ENDPOINT="+tc.endpoint)
output, err := command.CombinedOutput()
@@ -11,7 +11,7 @@ import (
"time"
)
func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) {
func TestNonprodEvidencePreflightRequiresExplicitAsteriskConfig(t *testing.T) {
tools := t.TempDir()
for name, script := range map[string]string{
"id": "if [ \"$1\" = -u ]; then echo 0; else exec /usr/bin/id \"$@\"; fi\n",
@@ -27,9 +27,9 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) {
}
root := t.TempDir()
command := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock",
"--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username,
"--asterisk-scope", "user", "--trunk", "saas-trunk-42", "--target", "15003164745", "--run-as", currentUser.Username,
"--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "evidence"),
"--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--preflight-only", "--", "/bin/true")
"--preflight-only", "--", "/bin/true")
command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN=/bin/true", "ASTERISK_CONFIG=")
output, err := command.CombinedOutput()
if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") {
@@ -38,7 +38,7 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) {
fromSaaS := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock",
"--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15803300952", "--run-as", currentUser.Username,
"--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "saas-evidence"),
"--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--preflight-only", "--", "/bin/true")
"--preflight-only", "--", "/bin/true")
fromSaaS.Env = command.Env
output, err = fromSaaS.CombinedOutput()
if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") {
@@ -47,14 +47,11 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) {
withoutPreflight := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock",
"--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username,
"--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "real-attempt"),
"--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--", "/bin/true")
"--", "/bin/true")
withoutPreflight.Env = command.Env
output, err = withoutPreflight.CombinedOutput()
if err == nil || !strings.Contains(string(output), "outside Asia/Shanghai 09:00-20:00") {
t.Fatalf("real call must remain blocked outside hours: err=%v output=%s", err, output)
}
if _, err := os.Stat(filepath.Join(root, "attempts.tsv")); !os.IsNotExist(err) {
t.Fatalf("out-of-hours real call reserved an attempt: %v", err)
if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") {
t.Fatalf("SaaS-governed off-hours call still requires host diagnostics: err=%v output=%s", err, output)
}
}
@@ -91,7 +88,7 @@ func TestNonprodUserAsteriskScopeUsesUserServiceAndConfiguredCLI(t *testing.T) {
command := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock",
"--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username,
"--interface", "any", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "evidence"),
"--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--preflight-only", "--", "/bin/true")
"--preflight-only", "--", "/bin/true")
command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN="+asterisk, "ASTERISK_CONFIG="+configFile,
"ASTERISK_LIBRARY_PATH="+tools, "TCPDUMP_BIN="+filepath.Join(tools, "tcpdump"), "TEST_CONFIG="+configFile,
"TEST_SERVICE_LOG="+serviceLog, "TEST_CLI_LOG="+cliLog, "TEST_CAPTURE_ARGS="+captureArgs)
@@ -145,7 +142,7 @@ func TestNonprodPreflightRejectsIncompleteCapturedEvidence(t *testing.T) {
command := exec.CommandContext(ctx, "bash", "../../deploys/test/nonprod-call-evidence.sh",
"--environment", "mock", "--trunk", "provider-primary", "--target", "15003164745",
"--interface", "lo", "--run-as", currentUser.Username, "--recording-dir", filepath.Join(tools, "recordings"),
"--evidence-dir", evidence, "--attempt-ledger", filepath.Join(tools, "attempts.tsv"), "--preflight-only", "--", "/bin/true")
"--evidence-dir", evidence, "--preflight-only", "--", "/bin/true")
command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN="+asterisk, "TCPDUMP_BIN="+tcpdump,
"TEST_EVIDENCE="+evidence, "TEST_DIAL_MARKER="+marker)
output, err := command.CombinedOutput()
+1 -1
View File
@@ -352,7 +352,7 @@ func (s *Store) ListAssignedTasks(dispatcherID string) ([]AssignedTask, error) {
}
// CanAdmit checks persisted discovery and human controls; it does not replace
// call-time whitelist, schedule, SIP load, quota or authorization checks.
// call-time SaaS event callee validation, task/trunk schedule, SIP load, quota or authorization checks.
func (s *Store) CanAdmit(dispatcherID string, tenantID int64, taskID string) (bool, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM dispatcher_tasks t