From 6153becb48435dd33270e2b7739d852d8d106b4f Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 6 Oct 2026 15:06:34 +0800 Subject: [PATCH] Remove local outbound time and attempt caps in favor of SaaS policy --- AGENTS.md | 8 +-- README.md | 4 +- contracts/local/manifest.json | 2 +- deploys/cell/README.md | 9 +-- deploys/test/README.md | 6 +- deploys/test/nonprod-call-evidence.sh | 67 ++----------------- deploys/test/saas-mock/README.md | 4 +- deploys/test/saas-mock/main_test.go | 8 +-- deploys/test/saas-mock/publish.go | 11 +-- deploys/test/saas-mock/publish_test.go | 14 ++-- deploys/test/saas-mock/server.go | 4 +- docs/thirds/saas-dispatcher.md | 2 +- internal/callwindow/window.go | 33 --------- internal/callwindow/window_test.go | 49 -------------- internal/config/nonprod_call_gate_test.go | 8 +-- .../config/nonprod_evidence_cleanup_test.go | 21 +++--- internal/store/store.go | 2 +- 17 files changed, 52 insertions(+), 200 deletions(-) delete mode 100644 internal/callwindow/window.go delete mode 100644 internal/callwindow/window_test.go diff --git a/AGENTS.md b/AGENTS.md index dd40da4..92eb8d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 目标。问题根因不明时补可观测性并诚实报告,不能用静默兜底伪装修复。 diff --git a/README.md b/README.md index cf12e47..193ca17 100644 --- a/README.md +++ b/README.md @@ -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 消息和样例自动发起真实呼叫。 ## 导航 diff --git a/contracts/local/manifest.json b/contracts/local/manifest.json index 8db5219..fe61045 100644 --- a/contracts/local/manifest.json +++ b/contracts/local/manifest.json @@ -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" diff --git a/deploys/cell/README.md b/deploys/cell/README.md index 2bcc220..1b08014 100644 --- a/deploys/cell/README.md +++ b/deploys/cell/README.md @@ -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. diff --git a/deploys/test/README.md b/deploys/test/README.md index 85bdb6c..f4f5d76 100644 --- a/deploys/test/README.md +++ b/deploys/test/README.md @@ -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 diff --git a/deploys/test/nonprod-call-evidence.sh b/deploys/test/nonprod-call-evidence.sh index 34660b5..deb96fe 100755 --- a/deploys/test/nonprod-call-evidence.sh +++ b/deploys/test/nonprod-call-evidence.sh @@ -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=/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" diff --git a/deploys/test/saas-mock/README.md b/deploys/test/saas-mock/README.md index 2651d9b..dee9912 100644 --- a/deploys/test/saas-mock/README.md +++ b/deploys/test/saas-mock/README.md @@ -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` 必须相同,任务须属于同一租户且文件名为 `.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` 必须相同,任务须属于同一租户且文件名为 `.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 --listen <地址:端口> --tls-cert <证书文件> --tls-key <私钥文件>`。 - 服务以标准 `X-DISPATCHER-id` 和 `X-DISPATCHER-SECRET-KEY` 校验 Dispatcher,再提供五类只读配置。错误的归属、资源、租户、快照或消息队列会导致拒绝启动/读取。 -- 单次投递另起命令:`go run ./deploys/test/saas-mock --data <私有目录> --dispatcher-id --publish-event-id <唯一事件号> --publish-task-id <单线路任务号> --publish-callee --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 --publish-event-id <唯一事件号> --publish-task-id <单线路任务号> --publish-callee --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。 diff --git a/deploys/test/saas-mock/main_test.go b/deploys/test/saas-mock/main_test.go index 13cfdbd..06b3f34 100644 --- a/deploys/test/saas-mock/main_test.go +++ b/deploys/test/saas-mock/main_test.go @@ -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) } } diff --git a/deploys/test/saas-mock/publish.go b/deploys/test/saas-mock/publish.go index f005337..ef7e72c 100644 --- a/deploys/test/saas-mock/publish.go +++ b/deploys/test/saas-mock/publish.go @@ -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") } diff --git a/deploys/test/saas-mock/publish_test.go b/deploys/test/saas-mock/publish_test.go index 890f4fc..1c799e4 100644 --- a/deploys/test/saas-mock/publish_test.go +++ b/deploys/test/saas-mock/publish_test.go @@ -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) } } } diff --git a/deploys/test/saas-mock/server.go b/deploys/test/saas-mock/server.go index 027bc4b..bf20d4c 100644 --- a/deploys/test/saas-mock/server.go +++ b/deploys/test/saas-mock/server.go @@ -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") diff --git a/docs/thirds/saas-dispatcher.md b/docs/thirds/saas-dispatcher.md index 8113562..1ca5423 100644 --- a/docs/thirds/saas-dispatcher.md +++ b/docs/thirds/saas-dispatcher.md @@ -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 参数验证不等于真实供应商验收。 diff --git a/internal/callwindow/window.go b/internal/callwindow/window.go deleted file mode 100644 index 481e44f..0000000 --- a/internal/callwindow/window.go +++ /dev/null @@ -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) -} diff --git a/internal/callwindow/window_test.go b/internal/callwindow/window_test.go deleted file mode 100644 index 84c98d9..0000000 --- a/internal/callwindow/window_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/internal/config/nonprod_call_gate_test.go b/internal/config/nonprod_call_gate_test.go index 2d12bfa..2336aa6 100644 --- a/internal/config/nonprod_call_gate_test.go +++ b/internal/config/nonprod_call_gate_test.go @@ -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() diff --git a/internal/config/nonprod_evidence_cleanup_test.go b/internal/config/nonprod_evidence_cleanup_test.go index d9d3a51..f4fedd6 100644 --- a/internal/config/nonprod_evidence_cleanup_test.go +++ b/internal/config/nonprod_evidence_cleanup_test.go @@ -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() diff --git a/internal/store/store.go b/internal/store/store.go index 87642f8..3567f73 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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