Simplify sip-call CLI and isolate temporary SIP configuration
This commit is contained in:
@@ -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