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

209 lines
7.5 KiB
Go

package sipcall
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
type cliFunc func(context.Context, string) ([]byte, error)
const testInclude = "\n#tryinclude sip-call-managed.conf\n"
// 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")
info, err := privateConfigInfo(basePath, os.Geteuid())
if err != nil {
return nil, err
}
base, err := os.ReadFile(basePath)
if err != nil {
return nil, err
}
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 {
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)
}
owner := info.Sys().(*syscall.Stat_t)
if err := file.Chown(int(owner.Uid), int(owner.Gid)); err != nil {
return nil, errors.Join(err, file.Close(), os.Remove(managed))
}
_, writeErr := file.WriteString(text)
managedInfo, statErr := file.Stat()
err = errors.Join(writeErr, file.Sync(), statErr, file.Close())
if err != nil {
return nil, errors.Join(err, os.Remove(managed))
}
restore = func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
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 {
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")
}
if e := os.Remove(managed); e != nil {
return e
}
if e := reloadPJSIP(cleanupCtx, cli); e != nil {
return e
}
if e := checkConfigUnchanged(basePath, info, base); e != nil {
return fmt.Errorf("original configuration after reload: %w", e)
}
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 = checkConfigUnchanged(basePath, info, base); 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))
}
// uid is explicit so mismatched ownership and sudo can be tested without root.
func privateConfigInfo(path string, uid int) (os.FileInfo, error) {
if uid == 0 {
return nil, errors.New("do not sudo sip-call: run as the Asterisk configuration owner; grant capture permission only to tcpdump")
}
info, err := os.Lstat(path)
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)")
}
owner, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil, errors.New("cannot determine Asterisk configuration owner")
}
if int(owner.Uid) != uid {
return nil, fmt.Errorf("Asterisk configuration owner uid %d differs from sip-call uid %d; stop and restore the correct owner, do not sudo sip-call", owner.Uid, uid)
}
return info, nil
}
func checkConfigMetadata(path string, expected os.FileInfo) error {
info, err := privateConfigInfo(path, os.Geteuid())
if err != nil {
return err
}
currentOwner := info.Sys().(*syscall.Stat_t)
originalOwner := expected.Sys().(*syscall.Stat_t)
if currentOwner.Uid != originalOwner.Uid || currentOwner.Gid != originalOwner.Gid || info.Mode().Perm() != expected.Mode().Perm() {
return errors.New("Asterisk configuration metadata changed; original owner/group/permissions must be preserved")
}
return nil
}
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
}
if !bytes.Equal(current, original) {
return fmt.Errorf("%s changed during test; refusing to remove temporary SIP configuration", filepath.Base(path))
}
return nil
}