209 lines
7.5 KiB
Go
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
|
|
}
|