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
+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" {