diff --git a/AGENTS.md b/AGENTS.md index 7a58a26..5d0b923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ | 线路 | SIP 服务端(历史登记,非已验证线路) | 主叫标识 | 被叫前缀 | | --- | --- | --- | --- | | 数企 | `61.132.228.221:5060` | `BD93205882` | `7089` | -| 中鼎 | `60.171.24.90:5060` | `mbkq` | 无 | +| 中鼎 | `222.186.130.228:5060` | `mbkq` | 无 | | 百应 | `160.202.254.79:5060` | `KQ91526` | `mka755108` | diff --git a/cmd/sip-call/README.md b/cmd/sip-call/README.md index 3513b5a..6d294bf 100644 --- a/cmd/sip-call/README.md +++ b/cmd/sip-call/README.md @@ -29,7 +29,7 @@ chmod 600 sip-xx.env ## 自动配置与退出 -取得该 Asterisk 配置目录的独占测试锁,确认空闲后保存原 `pjsip.conf` 到本次私有目录的 `pjsip.conf.before`。临时增加独立 include/endpoint/AOR,reload 后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。 +取得该 Asterisk 配置目录的独占测试锁,确认空闲后保存原 `pjsip.conf` 到本次私有目录的 `pjsip.conf.before`。临时增加独立 include/endpoint/AOR,使用原生 `module reload res_pjsip.so` 并核对明确的成功响应,不依赖可选的 `pjsip reload` 别名,不把 CLI 退出码 0 当作加载成功;随后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。 收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后恢复原 `pjsip.conf` 原字节、删除本次专用配置、reload 并确认 endpoint 已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。 diff --git a/internal/sipcall/configuration.go b/internal/sipcall/configuration.go index f097e73..7adc638 100644 --- a/internal/sipcall/configuration.go +++ b/internal/sipcall/configuration.go @@ -3,6 +3,7 @@ package sipcall import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "os" @@ -88,7 +89,7 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun if e := os.Remove(managed); e != nil { return e } - if _, e := cli(cleanupCtx, "pjsip reload"); e != nil { + if e := reloadPJSIP(cleanupCtx, cli); e != nil { return e } out, e := cli(cleanupCtx, "pjsip show endpoint "+c.Endpoint()) @@ -106,7 +107,7 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun if err = atomicConfig(basePath, appended); err != nil { return restore, err } - if _, err = cli(ctx, "pjsip reload"); err != nil { + if err = reloadPJSIP(ctx, cli); err != nil { return restore, err } endpoint, err := cli(ctx, "pjsip show endpoint "+c.Endpoint()) @@ -140,6 +141,21 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun return restore, nil } +// Asterisk CLI errors can exit zero. Confirm the native module response before +// checking loaded objects; pjsip reload is an optional alias, not a native command. +func reloadPJSIP(ctx context.Context, cli cliFunc) error { + out, err := cli(ctx, "module reload res_pjsip.so") + if err != nil { + return err + } + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == "Module 'res_pjsip.so' reloaded successfully." { + return nil + } + } + return fmt.Errorf("native PJSIP reload not confirmed (output_sha256=%x)", sha256.Sum256(out)) +} + func atomicConfig(path string, data []byte) error { f, err := os.CreateTemp(filepath.Dir(path), ".sip-call-write-*") if err != nil { diff --git a/internal/sipcall/configuration_test.go b/internal/sipcall/configuration_test.go index 78f3217..0df1d85 100644 --- a/internal/sipcall/configuration_test.go +++ b/internal/sipcall/configuration_test.go @@ -18,8 +18,8 @@ func configCLI(c Config, basePath string) cliFunc { return []byte("0 active channels\n0 active calls\n"), nil case "pjsip show transports": return []byte("Transport: go-sip-udp udp 0 0 0.0.0.0:5060\n"), nil - case "pjsip reload": - return []byte("Module reloaded\n"), nil + case "module reload res_pjsip.so": + return []byte("Module 'res_pjsip.so' reloaded successfully.\n"), nil case "pjsip show endpoint " + c.Endpoint(): data, err := os.ReadFile(basePath) if err != nil { @@ -116,7 +116,7 @@ func TestConfigurationFailsClosedOnRuntimeMismatch(t *testing.T) { if failure == "caller" && strings.HasPrefix(s, "pjsip show endpoint") { return bytes.ReplaceAll(out, []byte("BD1234"), []byte("WRONG")), nil } - if failure == "reload" && s == "pjsip reload" { + if failure == "reload" && s == "module reload res_pjsip.so" { return nil, errors.New("reload failed") } if failure == "aor" && strings.HasPrefix(s, "pjsip show aor") { diff --git a/internal/sipcall/reload_test.go b/internal/sipcall/reload_test.go new file mode 100644 index 0000000..f7bac85 --- /dev/null +++ b/internal/sipcall/reload_test.go @@ -0,0 +1,84 @@ +package sipcall + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "testing" +) + +func TestNativePJSIPModuleReloadIsUsedAndConfirmed(t *testing.T) { + c, path, base := stageFixture(t) + fixture := configCLI(c, path) + var reloads int + cli := func(ctx context.Context, command string) ([]byte, error) { + switch command { + case "pjsip reload": + return []byte("No such command 'pjsip reload' (type 'core show help pjsip reload' for other possible commands)\n"), nil + case "module reload res_pjsip.so": + reloads++ + return []byte("Module 'res_pjsip.so' reloaded successfully.\n"), nil + default: + return fixture(ctx, command) + } + } + restore, err := applyConfiguration(context.Background(), c, cli) + if err != nil { + t.Fatalf("supported native reload failed: %v", err) + } + if err := restore(); err != nil { + t.Fatal(err) + } + if reloads != 2 { + t.Fatalf("expected native module reload for apply and restore, got %d", reloads) + } + data, err := os.ReadFile(path) + if err != nil || !bytes.Equal(data, base) { + t.Fatal("original configuration not restored") + } +} + +func TestPJSIPReloadRejectsZeroExitWithoutModuleConfirmation(t *testing.T) { + for _, output := range []string{ + "No such command 'module reload res_pjsip.so'\n", + "Module 'res_pjsip.so' could not be reloaded.\n", + "Module 'res_other.so' reloaded successfully.\n", + "", + } { + for _, phase := range []string{"apply", "restore"} { + t.Run(fmt.Sprintf("%s/%q", phase, output), func(t *testing.T) { + c, path, _ := stageFixture(t) + fixture := configCLI(c, path) + var reloads, runtimeReads int + cli := func(ctx context.Context, command string) ([]byte, error) { + if command == "module reload res_pjsip.so" { + reloads++ + if phase == "apply" || reloads == 2 { + return []byte(output), nil + } + return []byte("Module 'res_pjsip.so' reloaded successfully.\n"), nil + } + if strings.HasPrefix(command, "pjsip show endpoint ") { + runtimeReads++ + } + return fixture(ctx, command) + } + restore, err := applyConfiguration(context.Background(), c, cli) + if phase == "apply" { + if err == nil || !strings.Contains(err.Error(), "reload not confirmed") || runtimeReads != 0 { + t.Fatalf("unconfirmed reload must fail before readback: error=%v reads=%d", err, runtimeReads) + } + return + } + if err != nil { + t.Fatal(err) + } + if err = restore(); err == nil || !strings.Contains(err.Error(), "reload not confirmed") { + t.Fatalf("unconfirmed restoration reload accepted: %v", err) + } + }) + } + } +}