// Package sipcall implements the isolated, single-call native Asterisk line test. package sipcall import ( "bufio" "errors" "fmt" "io" "net/netip" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) type Config struct { ID, Server, CallerID, Prefix string Port int ConfigDir, Binary, LibraryDir, OutputDir, Interface string Ring, Hold time.Duration resultDir string } var identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$`) var caller = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$`) var prefix = regexp.MustCompile(`^[A-Za-z0-9]{0,16}$`) var numberPattern = regexp.MustCompile(`^[0-9]{1,32}$`) func LoadConfig(path string) (Config, error) { info, err := os.Lstat(path) if err != nil { return Config{}, fmt.Errorf("inspect SIP ENV: %w", err) } if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { return Config{}, errors.New("SIP ENV must be a private regular file (0600), not a symlink") } f, err := os.Open(path) if err != nil { return Config{}, err } defer f.Close() return ParseConfig(f) } // ParseConfig never evaluates shell syntax, substitutes variables, or accepts // unknown/duplicate fields. Empty prefix must still be explicitly declared. func ParseConfig(r io.Reader) (Config, error) { keys := []string{"SIP_ID", "SIP_SERVER", "SIP_PORT", "SIP_CALLER_ID", "SIP_PREFIX", "SIP_TRANSPORT", "SIP_AUTH", "SIP_REGISTER", "SIP_CODEC", "ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "OUTPUT_DIR", "RING_SECONDS", "HOLD_SECONDS", "DEBUG_INTERFACE"} allowed := map[string]bool{} for _, k := range keys { allowed[k] = true } values := map[string]string{} data, err := io.ReadAll(io.LimitReader(r, 65537)) if err != nil { return Config{}, err } if len(data) > 65536 { return Config{}, errors.New("SIP ENV exceeds 64 KiB") } scanner := bufio.NewScanner(strings.NewReader(string(data))) line := 0 for scanner.Scan() { line++ s := strings.TrimSpace(scanner.Text()) if s == "" || strings.HasPrefix(s, "#") { continue } k, v, ok := strings.Cut(s, "=") k = strings.TrimSpace(k) v = strings.TrimSpace(v) if !ok || !allowed[k] { return Config{}, fmt.Errorf("SIP ENV line %d: unknown or malformed field", line) } if _, ok = values[k]; ok { return Config{}, fmt.Errorf("SIP ENV line %d: duplicate %s", line, k) } if strings.ContainsAny(v, "\x00\r\n\"'`$") { return Config{}, fmt.Errorf("SIP ENV %s: shell syntax and control characters are not supported", k) } values[k] = v } if err := scanner.Err(); err != nil { return Config{}, fmt.Errorf("read SIP ENV: %w", err) } for _, k := range keys { if k == "DEBUG_INTERFACE" { continue } v, ok := values[k] if !ok || (v == "" && k != "SIP_PREFIX") { return Config{}, fmt.Errorf("SIP ENV %s required", k) } } for k, want := range map[string]string{"SIP_TRANSPORT": "udp", "SIP_AUTH": "ip", "SIP_REGISTER": "no", "SIP_CODEC": "alaw"} { if values[k] != want { return Config{}, fmt.Errorf("%s must be %s; other line modes are not supported", k, want) } } addr, err := netip.ParseAddr(values["SIP_SERVER"]) if err != nil || !addr.Is4() || addr.IsUnspecified() || addr.IsMulticast() { return Config{}, errors.New("SIP_SERVER must be a unicast IPv4 address") } port, err := strconv.Atoi(values["SIP_PORT"]) if err != nil || port < 1 || port > 65535 { return Config{}, errors.New("SIP_PORT must be between 1 and 65535") } if !identifier.MatchString(values["SIP_ID"]) || !caller.MatchString(values["SIP_CALLER_ID"]) || !prefix.MatchString(values["SIP_PREFIX"]) { return Config{}, errors.New("invalid SIP_ID, SIP_CALLER_ID or SIP_PREFIX") } ring, err := seconds(values["RING_SECONDS"], 120) if err != nil { return Config{}, fmt.Errorf("RING_SECONDS: %w", err) } hold, err := seconds(values["HOLD_SECONDS"], 30) if err != nil { return Config{}, fmt.Errorf("HOLD_SECONDS: %w", err) } for _, k := range []string{"ASTERISK_CONFIG_DIR", "ASTERISK_BIN", "ASTERISK_LIBRARY_DIR", "OUTPUT_DIR"} { if !filepath.IsAbs(values[k]) { return Config{}, fmt.Errorf("%s must be an absolute path", k) } } iface := values["DEBUG_INTERFACE"] if iface != "" && !regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,64}$`).MatchString(iface) { return Config{}, errors.New("invalid DEBUG_INTERFACE") } return Config{ID: values["SIP_ID"], Server: values["SIP_SERVER"], Port: port, CallerID: values["SIP_CALLER_ID"], Prefix: values["SIP_PREFIX"], ConfigDir: values["ASTERISK_CONFIG_DIR"], Binary: values["ASTERISK_BIN"], LibraryDir: values["ASTERISK_LIBRARY_DIR"], OutputDir: values["OUTPUT_DIR"], Ring: ring, Hold: hold, Interface: iface}, nil } func seconds(s string, max int) (time.Duration, error) { n, err := strconv.Atoi(s) if err != nil || n < 1 || n > max { return 0, fmt.Errorf("must be between 1 and %d", max) } return time.Duration(n) * time.Second, nil } func (c Config) Route(number string) (string, error) { if !numberPattern.MatchString(number) { return "", errors.New("callee must contain 1–32 ASCII digits") } return c.Prefix + number, nil } func (c Config) Endpoint() string { return "siptest-" + c.ID }