146 lines
5.2 KiB
Go
146 lines
5.2 KiB
Go
// 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 }
|