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

146 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 }