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
+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"})