354 lines
11 KiB
Go
354 lines
11 KiB
Go
package sipcall
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
)
|
|
|
|
func configCLI(c Config, basePath string) cliFunc {
|
|
return func(_ context.Context, command string) ([]byte, error) {
|
|
switch command {
|
|
case "core show channels count":
|
|
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 "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 {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
func stageFixture(t *testing.T) (Config, string, []byte) {
|
|
t.Helper()
|
|
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" + testInclude)
|
|
if err := os.WriteFile(path, base, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c, path, base
|
|
}
|
|
|
|
func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) {
|
|
c, path, base := stageFixture(t)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
|
|
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)
|
|
}
|
|
data, err := os.ReadFile(managed)
|
|
if err != nil || !bytes.Contains(data, []byte("from_user=BD1234")) {
|
|
t.Fatal("requested identity not configured")
|
|
}
|
|
if err = restore(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkConfigMetadata(path, info); err != nil {
|
|
t.Fatalf("restored owner/group/mode: %v", err)
|
|
}
|
|
data, err = os.ReadFile(path)
|
|
if err != nil || !bytes.Equal(data, base) {
|
|
t.Fatal("business config bytes changed")
|
|
}
|
|
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) {
|
|
_, path, base := stageFixture(t)
|
|
for _, uid := range []int{0, os.Geteuid() + 1} {
|
|
if _, err := privateConfigInfo(path, uid); err == nil || !strings.Contains(err.Error(), "sudo sip-call") {
|
|
t.Fatalf("uid %d was not rejected with actionable guidance: %v", uid, err)
|
|
}
|
|
}
|
|
if _, err := privateConfigInfo(path, os.Geteuid()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || !bytes.Equal(data, base) {
|
|
t.Fatal("owner preflight changed configuration")
|
|
}
|
|
}
|
|
|
|
type configOwnerInfo struct {
|
|
os.FileInfo
|
|
owner syscall.Stat_t
|
|
}
|
|
|
|
func (i configOwnerInfo) Sys() any { return &i.owner }
|
|
|
|
func TestConfigMetadataChecksBothOwnerAndGroup(t *testing.T) {
|
|
_, path, _ := stageFixture(t)
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, field := range []string{"uid", "gid"} {
|
|
owner := *info.Sys().(*syscall.Stat_t)
|
|
if field == "uid" {
|
|
owner.Uid++
|
|
} else {
|
|
owner.Gid++
|
|
}
|
|
if err := checkConfigMetadata(path, configOwnerInfo{FileInfo: info, owner: owner}); err == nil {
|
|
t.Fatalf("%s mismatch was ignored", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreRefusesChangedConfigMetadata(t *testing.T) {
|
|
c, path, base := stageFixture(t)
|
|
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := restore(); err == nil {
|
|
t.Fatal("restoration silently replaced configuration with changed metadata")
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.Mode().Perm() != 0644 {
|
|
t.Fatalf("changed configuration was overwritten: %v, %v", info, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); err != nil {
|
|
t.Fatalf("recovery include was removed: %v", err)
|
|
}
|
|
current, err := os.ReadFile(path)
|
|
if err != nil || !bytes.Equal(current, base) {
|
|
t.Fatal("cleanup changed content after metadata mismatch")
|
|
}
|
|
}
|
|
|
|
func TestPrivateConfigInfoRejectsInvalidExistingFile(t *testing.T) {
|
|
for _, mode := range []string{"public", "symlink", "missing"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "pjsip.conf")
|
|
switch mode {
|
|
case "public":
|
|
if err := os.WriteFile(path, []byte("original"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "symlink":
|
|
target := filepath.Join(dir, "target")
|
|
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(target, path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigurationRefusesOldAndConcurrentState(t *testing.T) {
|
|
c, path, _ := stageFixture(t)
|
|
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
|
|
if err := os.WriteFile(managed, []byte("unfinished"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := applyConfiguration(context.Background(), c, configCLI(c, path)); err == nil {
|
|
t.Fatal("old test state overwritten")
|
|
}
|
|
if err := os.Remove(managed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
concurrent := []byte("concurrent config edit")
|
|
if err = os.WriteFile(path, concurrent, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = restore(); err == nil {
|
|
t.Fatal("concurrent changes swallowed")
|
|
}
|
|
data, _ := os.ReadFile(path)
|
|
if !bytes.Equal(data, concurrent) {
|
|
t.Fatal("concurrent bytes overwritten")
|
|
}
|
|
}
|
|
|
|
func TestConfigurationFailsClosedOnRuntimeMismatch(t *testing.T) {
|
|
for _, failure := range []string{"transport", "caller", "reload", "aor", "busy"} {
|
|
t.Run(failure, func(t *testing.T) {
|
|
c, path, base := stageFixture(t)
|
|
normal := configCLI(c, path)
|
|
cli := func(ctx context.Context, s string) ([]byte, error) {
|
|
out, err := normal(ctx, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if failure == "transport" && s == "pjsip show transports" {
|
|
return []byte("unknown"), nil
|
|
}
|
|
if failure == "caller" && strings.HasPrefix(s, "pjsip show endpoint") {
|
|
return bytes.ReplaceAll(out, []byte("BD1234"), []byte("WRONG")), nil
|
|
}
|
|
if failure == "reload" && s == "module reload res_pjsip.so" {
|
|
return nil, errors.New("reload failed")
|
|
}
|
|
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" {
|
|
return []byte("1 active channel"), nil
|
|
}
|
|
return out, nil
|
|
}
|
|
restore, err := applyConfiguration(context.Background(), c, cli)
|
|
if failure == "busy" {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = restore(); err == nil {
|
|
t.Fatal("restoration ignored active channel")
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatal("runtime mismatch accepted")
|
|
}
|
|
if restore != nil {
|
|
if err = restore(); err != nil && failure != "reload" {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
data, _ := os.ReadFile(path)
|
|
if !bytes.Equal(data, base) {
|
|
t.Fatal("failed apply changed original config")
|
|
}
|
|
})
|
|
}
|
|
}
|