Simplify sip-call CLI and isolate temporary SIP configuration
This commit is contained in:
@@ -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,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
@@ -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
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user