Files
go-sip/internal/sipcall/configuration.go
T

180 lines
6.0 KiB
Go

package sipcall
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
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.
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")
info, err := os.Lstat(basePath)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 {
return nil, errors.New("pjsip.conf must be a private regular file (0600)")
}
base, err := os.ReadFile(basePath)
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")
}
transport, err := cli(ctx, "pjsip show transports")
if err != nil {
return nil, err
}
found := false
for _, line := range strings.Split(string(transport), "\n") {
fields := strings.Fields(line)
if len(fields) >= 6 && fields[0] == "Transport:" && fields[1] == "go-sip-udp" && fields[2] == "udp" && fields[len(fields)-1] == "0.0.0.0:5060" {
found = true
}
}
if !found {
return nil, errors.New("approved static go-sip-udp transport is absent; no hot change or restart is permitted")
}
text := fmt.Sprintf("; Temporary isolated sip-call endpoint; no authentication or registration.\n[%s]\ntype=endpoint\ntransport=go-sip-udp\ncontext=go-sip-no-inbound\ndisallow=all\nallow=alaw\naors=%s-aor\nfrom_user=%s\ncallerid=\"%s\" <%s>\ndirect_media=no\n\n[%s-aor]\ntype=aor\ncontact=sip:%s:%d\n", c.Endpoint(), c.Endpoint(), c.CallerID, c.CallerID, c.CallerID, c.Endpoint(), c.Server, c.Port)
file, err := os.OpenFile(managed, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return nil, fmt.Errorf("test configuration already exists or cannot be created; do not overwrite: %w", err)
}
_, 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())
}
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()
channels, e := cli(cleanupCtx, "core show channels count")
if e != nil {
return e
}
m := activeChannels.FindSubmatch(channels)
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 := os.Remove(managed); e != nil {
return e
}
if e := reloadPJSIP(cleanupCtx, cli); e != nil {
return 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")
}
return nil
}
if err != nil {
return restore, err
}
if err = atomicConfig(basePath, appended); err != nil {
return restore, err
}
if err = reloadPJSIP(ctx, cli); err != nil {
return restore, err
}
endpoint, err := cli(ctx, "pjsip show endpoint "+c.Endpoint())
if err != nil {
return restore, err
}
for _, want := range []string{c.Endpoint() + "-aor", "alaw", "go-sip-udp", "go-sip-no-inbound"} {
if !bytes.Contains(endpoint, []byte(want)) {
return restore, errors.New("test endpoint runtime does not match requested AOR/codec/transport/context")
}
}
for _, pair := range [][2]string{{"from_user", c.CallerID}, {"callerid", fmt.Sprintf("\"%s\" <%s>", c.CallerID, c.CallerID)}} {
found := false
for _, line := range strings.Split(string(endpoint), "\n") {
k, v, ok := strings.Cut(line, ":")
if ok && strings.TrimSpace(k) == pair[0] && strings.TrimSpace(v) == pair[1] {
found = true
}
}
if !found {
return restore, errors.New("test endpoint runtime caller identity differs from requested caller")
}
}
aor, err := cli(ctx, "pjsip show aor "+c.Endpoint()+"-aor")
if err != nil {
return restore, err
}
if !bytes.Contains(aor, []byte(fmt.Sprintf("sip:%s:%d", c.Server, c.Port))) {
return restore, errors.New("test AOR runtime does not match requested SIP server")
}
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 {
return err
}
name := f.Name()
defer os.Remove(name)
_, writeErr := f.Write(data)
err = errors.Join(writeErr, f.Sync(), f.Close())
if 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())
}