fix(sip-call): require native PJSIP module reload confirmation

This commit is contained in:
2026-10-08 15:35:27 +08:00
parent 6bf305284a
commit 0a64b383c7
5 changed files with 107 additions and 7 deletions
+1 -1
View File
@@ -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` |
+1 -1
View File
@@ -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 加载代次。
+18 -2
View File
@@ -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 {
+3 -3
View File
@@ -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") {
+84
View File
@@ -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)
}
})
}
}
}