Simplify sip-call CLI and isolate temporary SIP configuration

This commit is contained in:
2026-10-09 10:58:30 +08:00
parent f5eebe638c
commit 6e028d4fea
6 changed files with 223 additions and 132 deletions
+3 -1
View File
@@ -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 兜底或偷偷启用旧执行字段。已接纳任务持久绑定原快照。
+9 -6
View File
@@ -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 配置备份,也不是拨号必需目录。本次修改不删除这些旧工具或任何历史证据。
## 输出与证据
+5 -14
View File
@@ -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 {
+65 -18
View File
@@ -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"})
+39 -67
View File
@@ -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
}
+102 -26
View File
@@ -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" {