diff --git a/AGENTS.md b/AGENTS.md index 6d155a5..c297153 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,10 +92,12 @@ - 2026-10-08 使用者确认当前已完成改动合入 main 并推送,远端 `31ebda9` 核对成功后才进行数企独立 sip-call 单通 DEBUG 验证。仅 1 个 INVITE、原事务 100/183/200 与 ACK,原生确认接通;保持 3 秒后原通道两次读取确认 404、结果 `connected/end_confirmed=true`、原配置恢复、Agent 恢复 active、Dispatcher 仍停止。三线 ENV、NAT、旧数据库未改,无 AI/OSS、重拨或换线;原 PCAP 未捕获 BYE/RTP,不能宣称对端 BYE 回执、媒体/声音或 ASR/LLM/TTS 全链路通过。此事实不解除中鼎新地址无回复、静态 transport 公网地址缺项及旧 SQLite 布局拒绝的独立阻断;见 [`docs/evidence/shuqi-sip-call-after-main-push-20261008.md`](docs/evidence/shuqi-sip-call-after-main-push-20261008.md)。 - 2026-10-08 使用者指定中鼎改为 `222.186.130.228:5060`、主叫保持 `mbkq`,ENV 与 SaaS 测试 SIP revision 15 已更新,其余两线未变;源码 `310a14f` 的 Agent/Dispatcher 与 sip-call 已部署。获批独立工具 DEBUG 只实际发出一通:1 个 INVITE transaction、6 包(5 次协议重传),0 SIP 回复、未接通;原 `unknown` 结果保留,后续精确原通道 404 的操作者证据先私密落盘后才恢复本通原始配置,未改写为正式终结结果。Agent active、Dispatcher 保持原来 inactive、Asterisk 未重启。新版拒绝旧 SQLite 6 表/version 2,使用者未批准替代运行目录,不能自动迁移、清理或以新空库绕过;恢复后的业务中鼎 AOR 仍为旧地址,不能把工具临时加载等同于持久业务配置生效。DEBUG 还确认 transport 未设外部信令/媒体地址、报文公布私网地址,但旧中鼎抓包在同样设置下仍返回 480,不能把此次无回复唯一归因于 NAT 或断言供应商故障;未修改静态 transport、重启 Asterisk或重复盲拨。现场与回归边界见 [`docs/evidence/zhongding-new-ip-sip-call-debug-20261008.md`](docs/evidence/zhongding-new-ip-sip-call-debug-20261008.md)。 -- 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。默认运行不调用抓包工具;使用者进一步要求生产和测试安装环境均统一预装 tcpdump/tshark,不按环境跳过,但抓包仍须显式 `--debug/-D`,不足时不能静默降级。登记测试机现已安装并核对 tcpdump 4.99.5、TShark 4.4.19 的 SIP/RTP 解析能力;未开启抓包或更改抓包权限,不能把工具安装当作逐通抓证、线路接通或生产签收。部署来源、文件 hash 和只读主机事实见 [`docs/evidence/sip-call-test-deployment-20261008.md`](docs/evidence/sip-call-test-deployment-20261008.md)。独立工具不属于生产发布制品,也不授权真实试拨。 +- 2026-10-08 使用者批准独立测试工具 [`cmd/sip-call`](cmd/sip-call/README.md):单独编译,以 `sip-call [-D|--debug] <线路.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。默认运行不调用抓包工具;使用者进一步要求生产和测试安装环境均统一预装 tcpdump/tshark,不按环境跳过,但抓包仍须显式 `--debug/-D`,不足时不能静默降级。登记测试机现已安装并核对 tcpdump 4.99.5、TShark 4.4.19 的 SIP/RTP 解析能力;未开启抓包或更改抓包权限,不能把工具安装当作逐通抓证、线路接通或生产签收。部署来源、文件 hash 和只读主机事实见 [`docs/evidence/sip-call-test-deployment-20261008.md`](docs/evidence/sip-call-test-deployment-20261008.md)。独立工具不属于生产发布制品,也不授权真实试拨。 - 2026-10-09 使用者批准修复独立 `sip-call` 的 sudo 归属问题:先将测试机 `pjsip.conf` 恢复为 `rogee:rogee`、保留 `0600` 与原字节,再本地修复工具。主程序必须以配置所属用户运行,写入/抓包前拒绝 root 或归属不匹配;临时与恢复配置保留原用户/组及权限,恢复检查不得只看测试 endpoint 消失。另获明确批准,仅为测试机 `/usr/bin/tcpdump` 配置 `cap_net_raw,cap_net_admin=eip`,已用 `rogee` 验证启动与 SIGINT 停止,输出丢弃、零包、未生成流量;不对主程序提权,不用 sudo 整条命令。调试启动失败须区分“已请求但未启动”和默认“未启用”。归属修复阶段未部署新版工具、未重启服务、未拨号;随后使用者明确仅批准独立工具部署与版本/帮助检查,已将源码基线 `4b27ef3` 及本地 Go 差异固定的工具安装至 `/home/rogee/.local/bin/sip-call`,SHA-256 为 `c6999f84692e31704915450151f9c6c1a31b3e5920acc58a1d3164aafb0941ba`,原工具与来源清单私密保存在 `~/sip-call/tool-backups/20261009T021242Z-c6999f84692e/`;帮助命令通过,原配置和 ENV 未变。此次不启动抓包、不执行 tcpdump→TShark 联合自检、不重启服务、不拨号;此前普通用户中鼎等待接通超时的线路/网络/程序根因仍未证实。详见 [`docs/evidence/sip-call-ownership-repair-20261009.md`](docs/evidence/sip-call-ownership-repair-20261009.md)。 +- 2026-10-09 使用者确认独立 `sip-call` 改为 `sip-call [-D|--debug] <线路.env> <原始号码>`,直接移除 `call`/`--sip` 旧写法。临时线路改用主机预先配置且仅配置一次的 `#tryinclude sip-call-managed.conf`:工具只创建本次独立线路文件,确认通话结束及零活动通道后删除、reload 并核对 endpoint/AOR 已卸载;原 `pjsip.conf` 和业务线路不写入、不备份,输入 ENV 保留。入口缺失/重复、旧临时文件或配置变化明确拒绝;未知通道仍保留临时配置与证据,不自动删除。`tool-backups` 是安装时保存的旧工具及来源记录,非 SIP 配置备份,不自动删除。本次仅本地实现、测试和说明;主机添加固定入口、新版部署及拨号均未授权执行。 + ## 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 兜底或偷偷启用旧执行字段。已接纳任务持久绑定原快照。 diff --git a/cmd/sip-call/README.md b/cmd/sip-call/README.md index f07fe47..3477425 100644 --- a/cmd/sip-call/README.md +++ b/cmd/sip-call/README.md @@ -9,18 +9,19 @@ 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 +./dist/sip-call sip-xx.env 18601010101 # 需要本通抓包与 SIP/RTP 证据时: -./dist/sip-call call --sip sip-xx.env -D 18601010101 +./dist/sip-call -D sip-xx.env 18601010101 ``` -`--debug` 与 `-D` 等价。环境安装统一预装 tcpdump/tshark(生产与测试相同);预装不等于开启抓包。默认模式不调用或检查这些工具;调试模式要求两者可执行、指定接口可抓包且权限充分。调试抓包未就绪或在 Dial 前已退出就不拨号。参数后的号码是原始号码,仅添加该文件声明的前缀一次;不要自己先加线路前缀。 +命令格式为 `sip-call [-D|--debug] <线路.env> <原始号码>`,不再支持 `call` 子命令或 `--sip` 参数。`--debug` 与 `-D` 等价。环境安装统一预装 tcpdump/tshark(生产与测试相同);预装不等于开启抓包。默认模式不调用或检查这些工具;调试模式要求两者可执行、指定接口可抓包且权限充分。调试抓包未就绪或在 Dial 前已退出就不拨号。参数后的号码是原始号码,仅添加该文件声明的前缀一次;不要自己先加线路前缀。 **命令会真实拨号。每次执行都须另获线路和号码的明确授权;本文示例不是授权。** 独立工具已另经使用者批准部署到登记测试机;没有执行真实试拨。 ## 运行条件 - 在已有原生 Asterisk 的主机运行;已有 `asterisk.conf`、0600 的 `pjsip.conf`、原生 PJSIP/ARI 模块及私有 `ari-secret`。 +- `pjsip.conf` 须预先配置且仅配置一次 `#tryinclude sip-call-managed.conf`。这是主机的一次性准备;工具不会自动补写,缺少或重复入口就拒绝执行。该临时文件不存在时,Asterisk 正常忽略此入口。 - 以 Asterisk 配置的所属用户运行(登记测试机为 `rogee`),**不要 `sudo sip-call`**。root 或文件归属不匹配会在创建结果、抓包及改配置前明确拒绝;不自动改归属或放宽权限。 - 使用现有本地 ARI 用户 `go-sip-agent` 和已配置的 loopback HTTP 地址;密码只从 `ASTERISK_CONFIG_DIR/ari-secret` 在内存读取,不复制到 ENV 或结果。 - 已有静态 `go-sip-udp` UDP transport,绑定 `0.0.0.0:5060`;工具不更改静态 transport,不启动/重启 Asterisk。 @@ -41,11 +42,13 @@ sudo setcap cap_net_raw,cap_net_admin=eip /usr/bin/tcpdump ## 自动配置与退出 -取得该 Asterisk 配置目录的独占测试锁,确认空闲后保存原 `pjsip.conf` 到本次私有目录的 `pjsip.conf.before`。临时增加独立 include/endpoint/AOR,使用原生 `module reload res_pjsip.so` 并核对明确的成功响应,不依赖可选的 `pjsip reload` 别名,不把 CLI 退出码 0 当作加载成功;随后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。 +取得该 Asterisk 配置目录的独占测试锁,确认空闲和固定入口已准备后,只创建本次专用 `sip-call-managed.conf`,其中包含独立 endpoint/AOR。工具不改写、不备份 `pjsip.conf`,也不修改原有业务线路;输入的 `SIP.env` 只读且保留。使用原生 `module reload res_pjsip.so` 并核对明确的成功响应,不依赖可选的 `pjsip reload` 别名,不把 CLI 退出码 0 当作加载成功;随后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。 -收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后恢复原 `pjsip.conf` 原字节及原所属用户/组、保持 `0600`,删除本次专用配置、reload 并确认 endpoint 已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。临时 include 同样使用原配置的所属用户/组;恢复前后及 reload 后均核对配置归属和权限,期间发生变化则明确报错并保留恢复资料,不覆盖变化后的文件。 +收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后,仅删除本次专用配置、reload 并确认 endpoint 和 AOR 均已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。临时文件使用原配置的所属用户/组和 `0600`;清理前核对原配置及临时文件的内容、归属和权限,reload 后再次核对原配置,发生变化则明确报错,不覆盖或删除变化后的文件。 -旧 `sip-call-managed.conf`/include、并发配置修改或未知通道状态均报错。无法确认结束时保留测试配置和原配置副本供人工排查,**不要直接重跑或自行清理**。恢复、抓包停止或证据解析失败都保留错误并以非零退出,不报告“全部正常”。 +已有 `sip-call-managed.conf`、并发配置修改或未知通道状态均报错。无法确认结束时保留临时配置及本次结果/证据供人工排查,**不要直接重跑或自行清理**。清理、抓包停止或证据解析失败都保留错误并以非零退出,不报告“全部正常”。 + +`~/sip-call/tool-backups/` 是安装时保存的旧版工具和来源清单,不是 SIP 配置备份,也不是拨号必需目录。本次修改不删除这些旧工具或任何历史证据。 ## 输出与证据 diff --git a/cmd/sip-call/main.go b/cmd/sip-call/main.go index 5a61c4b..e1741ec 100644 --- a/cmd/sip-call/main.go +++ b/cmd/sip-call/main.go @@ -26,30 +26,21 @@ func main() { } 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) + root := &cobra.Command{Use: "sip-call <线路.env> <原始号码>", Short: "单通 Asterisk 外呼线路测试(不接 AI)", SilenceUsage: true, SilenceErrors: true, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { + c, err := sipcall.LoadConfig(args[0]) if err != nil { return err } - if _, err = c.Route(args[0]); err != nil { + if _, err = c.Route(args[1]); err != nil { return err } - result, runErr := run(cmd.Context(), c, args[0], debug) + result, runErr := run(cmd.Context(), c, args[1], 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) + root.Flags().BoolVarP(&debug, "debug", "D", false, "开启本通抓包和 SIP/RTP 证据提取;缺少工具或权限就拒绝拨号") return root } func printReport(out io.Writer, r *sipcall.Report) error { diff --git a/cmd/sip-call/main_test.go b/cmd/sip-call/main_test.go index 9824f37..6b39d71 100644 --- a/cmd/sip-call/main_test.go +++ b/cmd/sip-call/main_test.go @@ -11,11 +11,10 @@ import ( "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 +func testLineENV(t *testing.T) string { + t.Helper() + env := filepath.Join(t.TempDir(), "line.env") + data := `SIP_ID=line SIP_SERVER=192.0.2.1 SIP_PORT=5060 SIP_CALLER_ID=BD123 @@ -32,19 +31,28 @@ 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 - } - } + if err := os.WriteFile(env, []byte(data), 0600); err != nil { + t.Fatal(err) + } + return env +} + +func TestCallCLIHasNoBusinessOrAIDependencies(t *testing.T) { + env := testLineENV(t) + for _, tc := range []struct { + args []string + debug bool + }{ + {[]string{env, "18601010101"}, false}, + {[]string{"-D", env, "18601010101"}, true}, + {[]string{"--debug", env, "18601010101"}, true}, + {[]string{env, "-D", "18601010101"}, true}, + {[]string{env, "--debug", "18601010101"}, true}, + } { 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" { + if debug != tc.debug || 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 @@ -52,9 +60,9 @@ DEBUG_INTERFACE=any out := &bytes.Buffer{} root.SetOut(out) root.SetErr(out) - root.SetArgs(args) + root.SetArgs(tc.args) if err := root.Execute(); err != nil || calls != 1 { - t.Fatalf("%v %v", args, err) + t.Fatalf("%v %v", tc.args, err) } if !strings.Contains(out.String(), "result.json") { t.Fatal("result path missing") @@ -63,7 +71,13 @@ DEBUG_INTERFACE=any } func TestCallCLIRejectsMissingOrMalformedInputsBeforeHostAccess(t *testing.T) { - for _, args := range [][]string{{}, {"call"}, {"call", "123"}, {"call", "--sip", "missing.env", "+123"}, {"call", "--sip", "missing.env", "123", "456"}, {"agent"}} { + env := testLineENV(t) + for _, args := range [][]string{ + {}, {"-D"}, {env}, {env, "123", "456"}, {"missing.env", "123"}, + {env, "+123"}, {env, ""}, {env, strings.Repeat("1", 33)}, {env, "123"}, + {"--unknown", env, "123"}, {"agent"}, {"call", "123"}, + {"call", env, "123"}, {"call", "--sip", env, "123"}, {"--sip", env, "123"}, + } { root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) { t.Fatal("invalid input must never access host") return nil, nil @@ -77,6 +91,39 @@ func TestCallCLIRejectsMissingOrMalformedInputsBeforeHostAccess(t *testing.T) { } } +func TestCallCLIHelpShowsPositionalArguments(t *testing.T) { + root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) { + t.Fatal("help must never access host") + return nil, nil + }) + out := &bytes.Buffer{} + root.SetOut(out) + root.SetArgs([]string{"--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, text := range []string{"sip-call <线路.env> <原始号码>", "-D, --debug"} { + if !strings.Contains(out.String(), text) { + t.Fatalf("help is missing %q: %s", text, out.String()) + } + } + if strings.Contains(out.String(), "--sip") || strings.Contains(out.String(), "Available Commands:") { + t.Fatalf("help still advertises subcommands or --sip: %s", out.String()) + } +} + +func TestCallCLIPropagatesRunFailure(t *testing.T) { + want := errors.New("unconfirmed call") + root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) { + return &sipcall.Report{Status: "unknown", Directory: "/tmp/test"}, want + }) + root.SetOut(&bytes.Buffer{}) + root.SetArgs([]string{testLineENV(t), "18601010101"}) + if err := root.Execute(); !errors.Is(err, want) { + t.Fatalf("run failure hidden: %v", err) + } +} + func TestDebugStartupFailureDoesNotClaimCaptureDisabled(t *testing.T) { out := &bytes.Buffer{} err := printReport(out, &sipcall.Report{Status: "not_dialed", Directory: "/tmp/test", Debug: true, Failure: "tcpdump startup failed"}) diff --git a/internal/sipcall/configuration.go b/internal/sipcall/configuration.go index 959da08..f35ede4 100644 --- a/internal/sipcall/configuration.go +++ b/internal/sipcall/configuration.go @@ -17,8 +17,8 @@ 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. +// Configuration uses a preconfigured optional include. Neither pjsip.conf nor +// go-sip-managed.conf is written or backed up. An unfinished test blocks reuse. 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") @@ -30,8 +30,15 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun 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") + includes := 0 + for _, line := range strings.Split(string(base), "\n") { + line, _, _ = strings.Cut(line, ";") + if strings.TrimSpace(line) == strings.TrimSpace(testInclude) { + includes++ + } + } + if includes != 1 { + return nil, errors.New("pjsip.conf must already contain exactly one #tryinclude sip-call-managed.conf; prepare it explicitly before using sip-call") } transport, err := cli(ctx, "pjsip show transports") if err != nil { @@ -57,19 +64,19 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun return nil, errors.Join(err, file.Close(), os.Remove(managed)) } _, 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()) + managedInfo, statErr := file.Stat() + err = errors.Join(writeErr, file.Sync(), statErr, file.Close()) + if err != nil { + return nil, errors.Join(err, os.Remove(managed)) } - 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() - if e := checkConfigMetadata(basePath, info); e != nil { - return fmt.Errorf("refuse configuration restore: %w", e) + if e := checkConfigUnchanged(basePath, info, base); e != nil { + return fmt.Errorf("refuse temporary configuration cleanup: %w", e) + } + if e := checkConfigUnchanged(managed, managedInfo, []byte(text)); e != nil { + return fmt.Errorf("refuse temporary configuration cleanup: %w", e) } channels, e := cli(cleanupCtx, "core show channels count") if e != nil { @@ -79,43 +86,27 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun 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 := checkConfigMetadata(basePath, info); e != nil { - return fmt.Errorf("restored configuration metadata: %w", e) - } if e := os.Remove(managed); e != nil { return e } if e := reloadPJSIP(cleanupCtx, cli); e != nil { return e } - if e := checkConfigMetadata(basePath, info); e != nil { - return fmt.Errorf("configuration metadata after reload: %w", e) + if e := checkConfigUnchanged(basePath, info, base); e != nil { + return fmt.Errorf("original configuration after reload: %w", 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") + for _, object := range []struct{ kind, name string }{{"endpoint", c.Endpoint()}, {"aor", c.Endpoint() + "-aor"}} { + out, e := cli(cleanupCtx, "pjsip show "+object.kind+" "+object.name) + if e != nil { + return e + } + if !strings.Contains(string(out), "Unable to find object") { + return fmt.Errorf("test %s removal could not be verified", object.kind) + } } return nil } - if err != nil { - return restore, err - } - if err = atomicConfig(basePath, appended); err != nil { + if err = checkConfigUnchanged(basePath, info, base); err != nil { return restore, err } if err = reloadPJSIP(ctx, cli); err != nil { @@ -202,35 +193,16 @@ func checkConfigMetadata(path string, expected os.FileInfo) error { return nil } -func atomicConfig(path string, data []byte) error { - info, err := privateConfigInfo(path, os.Geteuid()) +func checkConfigUnchanged(path string, info os.FileInfo, original []byte) error { + if err := checkConfigMetadata(path, info); err != nil { + return err + } + current, err := os.ReadFile(path) if err != nil { return err } - f, err := os.CreateTemp(filepath.Dir(path), ".sip-call-write-*") - if err != nil { - return err + if !bytes.Equal(current, original) { + return fmt.Errorf("%s changed during test; refusing to remove temporary SIP configuration", filepath.Base(path)) } - name := f.Name() - defer os.Remove(name) - owner := info.Sys().(*syscall.Stat_t) - if err := f.Chown(int(owner.Uid), int(owner.Gid)); err != nil { - return errors.Join(err, f.Close()) - } - _, writeErr := f.Write(data) - err = errors.Join(writeErr, f.Sync(), f.Close()) - if err != nil { - return err - } - if err = checkConfigMetadata(path, info); 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(), checkConfigMetadata(path, info)) + return nil } diff --git a/internal/sipcall/configuration_test.go b/internal/sipcall/configuration_test.go index af443e5..9e801be 100644 --- a/internal/sipcall/configuration_test.go +++ b/internal/sipcall/configuration_test.go @@ -29,8 +29,18 @@ func configCLI(c Config, basePath string) cliFunc { if !bytes.Contains(data, []byte(testInclude)) { return []byte("Unable to find object " + c.Endpoint()), nil } + if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); os.IsNotExist(err) { + return []byte("Unable to find object " + c.Endpoint()), nil + } else if err != nil { + return nil, err + } 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": + if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); os.IsNotExist(err) { + return []byte("Unable to find object " + c.Endpoint() + "-aor"), nil + } else if err != nil { + return nil, err + } return []byte(fmt.Sprintf("Contact: sip:%s:%d", c.Server, c.Port)), nil default: return nil, fmt.Errorf("unexpected CLI: %s", command) @@ -42,7 +52,7 @@ func stageFixture(t *testing.T) (Config, string, []byte) { 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") + base := []byte("; original bytes\n[go-sip-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\n" + testInclude) if err := os.WriteFile(path, base, 0600); err != nil { t.Fatal(err) } @@ -59,6 +69,17 @@ func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) { if err != nil { t.Fatal(err) } + current, err := os.ReadFile(path) + if err != nil || !bytes.Equal(current, base) { + t.Fatal("business config was modified while applying test line") + } + during, err := os.Stat(path) + if err != nil || !os.SameFile(info, during) || !info.ModTime().Equal(during.ModTime()) { + t.Fatal("business config was replaced or rewritten") + } + if _, err := os.Stat(filepath.Join(c.resultDir, "pjsip.conf.before")); !os.IsNotExist(err) { + t.Fatalf("whole configuration backup must not be created: %v", err) + } managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf") if err := checkConfigMetadata(managed, info); err != nil { t.Fatalf("temporary include owner/group/mode: %v", err) @@ -80,6 +101,10 @@ func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) { if _, err = os.Stat(managed); !os.IsNotExist(err) { t.Fatal("temporary file not removed") } + after, err := os.Stat(path) + if err != nil || !os.SameFile(info, after) || !info.ModTime().Equal(after.ModTime()) { + t.Fatal("business config was replaced or rewritten during cleanup") + } } func TestPrivateConfigInfoRejectsRootAndDifferentOwner(t *testing.T) { @@ -124,25 +149,6 @@ func TestConfigMetadataChecksBothOwnerAndGroup(t *testing.T) { } } -func TestAtomicConfigPreservesMetadata(t *testing.T) { - _, path, _ := stageFixture(t) - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - data := []byte("replacement configuration\n") - if err := atomicConfig(path, data); err != nil { - t.Fatal(err) - } - if err := checkConfigMetadata(path, info); err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(path) - if err != nil || !bytes.Equal(got, data) { - t.Fatal("configuration replacement content mismatch") - } -} - func TestRestoreRefusesChangedConfigMetadata(t *testing.T) { c, path, base := stageFixture(t) restore, err := applyConfiguration(context.Background(), c, configCLI(c, path)) @@ -163,12 +169,12 @@ func TestRestoreRefusesChangedConfigMetadata(t *testing.T) { t.Fatalf("recovery include was removed: %v", err) } current, err := os.ReadFile(path) - if err != nil || bytes.Equal(current, base) { - t.Fatal("restoration changed content after metadata mismatch") + if err != nil || !bytes.Equal(current, base) { + t.Fatal("cleanup changed content after metadata mismatch") } } -func TestAtomicConfigRejectsInvalidExistingFile(t *testing.T) { +func TestPrivateConfigInfoRejectsInvalidExistingFile(t *testing.T) { for _, mode := range []string{"public", "symlink", "missing"} { t.Run(mode, func(t *testing.T) { dir := t.TempDir() @@ -187,8 +193,78 @@ func TestAtomicConfigRejectsInvalidExistingFile(t *testing.T) { t.Fatal(err) } } - if err := atomicConfig(path, []byte("replacement")); err == nil { - t.Fatal("invalid configuration was replaced") + if _, err := privateConfigInfo(path, os.Geteuid()); err == nil { + t.Fatal("invalid configuration was accepted") + } + }) + } +} + +func TestConfigurationRequiresOnePreconfiguredInclude(t *testing.T) { + for _, include := range []string{"", "; #tryinclude sip-call-managed.conf\n", "#include sip-call-managed.conf\n", testInclude + testInclude} { + t.Run(include, func(t *testing.T) { + c, path, base := stageFixture(t) + base = append(bytes.TrimSuffix(base, []byte(testInclude)), []byte(include)...) + if err := os.WriteFile(path, base, 0600); err != nil { + t.Fatal(err) + } + cli := func(context.Context, string) ([]byte, error) { + t.Fatal("missing or duplicate include must fail before accessing Asterisk") + return nil, nil + } + restore, err := applyConfiguration(context.Background(), c, cli) + if err == nil || !strings.Contains(err.Error(), "#tryinclude sip-call-managed.conf") || restore != nil { + t.Fatalf("invalid preparation was not rejected: %v", err) + } + current, err := os.ReadFile(path) + if err != nil || !bytes.Equal(current, base) { + t.Fatal("tool silently prepared or modified the original config") + } + if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); !os.IsNotExist(err) { + t.Fatalf("temporary config created without a valid include: %v", err) + } + }) + } +} + +func TestConfigurationCleanupPreservesChangedTemporaryFile(t *testing.T) { + c, path, _ := 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") + changed := []byte("concurrent temporary config edit") + if err := os.WriteFile(managed, changed, 0600); err != nil { + t.Fatal(err) + } + if err := restore(); err == nil { + t.Fatal("cleanup deleted a changed temporary file") + } + current, err := os.ReadFile(managed) + if err != nil || !bytes.Equal(current, changed) { + t.Fatal("changed temporary file was not preserved") + } +} + +func TestConfigurationCleanupConfirmsEndpointAndAORRemoval(t *testing.T) { + for _, object := range []string{"endpoint", "aor"} { + t.Run(object, func(t *testing.T) { + c, path, _ := stageFixture(t) + fixture := configCLI(c, path) + cli := func(ctx context.Context, command string) ([]byte, error) { + out, err := fixture(ctx, command) + if strings.HasPrefix(command, "pjsip show "+object+" ") && bytes.Contains(out, []byte("Unable to find object")) { + return []byte("stale runtime object"), nil + } + return out, err + } + restore, err := applyConfiguration(context.Background(), c, cli) + if err != nil { + t.Fatal(err) + } + if err := restore(); err == nil || !strings.Contains(err.Error(), object) { + t.Fatalf("stale %s was reported as cleaned: %v", object, err) } }) } @@ -242,7 +318,7 @@ func TestConfigurationFailsClosedOnRuntimeMismatch(t *testing.T) { if failure == "reload" && s == "module reload res_pjsip.so" { return nil, errors.New("reload failed") } - if failure == "aor" && strings.HasPrefix(s, "pjsip show aor") { + if failure == "aor" && strings.HasPrefix(s, "pjsip show aor") && !bytes.Contains(out, []byte("Unable to find object")) { return []byte("sip:192.0.2.99:5060"), nil } if failure == "busy" && s == "core show channels count" {