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 }