diff --git a/deploys/cell/README.md b/deploys/cell/README.md index 2986919..1383479 100644 --- a/deploys/cell/README.md +++ b/deploys/cell/README.md @@ -22,8 +22,14 @@ installs `go-sip-asterisk.service` under `systemd --user`. Production requires `loginctl enable-linger rogee` and a verified reboot-persistent `enabled+active` service; `--nonprod` allows a session-scoped Debian 12 native build but does not certify reboot persistence. The management-approved static `pjsip.conf`, -ARI and RTP configuration must be supplied separately. The bundled stage has -no live SIP trunk or dialing authorization; verify loaded endpoints and contacts +ARI and RTP configuration must be supplied separately. For the isolated +nonproduction user service only, `configure-asterisk-user-ari.sh` creates +private `ari.conf`, loopback-only `http.conf` and an owner-only `ari-secret` +without printing credentials, overwriting existing files or restarting the +service. Restart the user service only after separately verifying zero active +calls, then read back ARI HTTP and both `enabled`/`active` state. Do not treat +this local test setup as a management-approved production configuration. +The bundled stage has no live SIP trunk or dialing authorization; verify loaded endpoints and contacts before any call. Do not substitute another Asterisk version or a container image. The Go Agent package only consumes the resulting approved Cell artifact and reports its applied revision. Local Mock fixtures do not prove real services. diff --git a/deploys/cell/configure-asterisk-user-ari.sh b/deploys/cell/configure-asterisk-user-ari.sh new file mode 100755 index 0000000..617a414 --- /dev/null +++ b/deploys/cell/configure-asterisk-user-ari.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Prepare private ARI/HTTP config for the native nonproduction user service. +# Does not change the service state; restart only after confirming zero calls. +set -euo pipefail + +config="$HOME/.config/go-sip-asterisk" +[[ -d "$config" && -r "$config/asterisk.conf" ]] || { echo 'user Asterisk config missing; fail-closed' >&2; exit 1; } +for name in ari.conf http.conf ari-secret; do + [[ ! -e "$config/$name" && ! -L "$config/$name" ]] || { echo "$name already exists; refusing to overwrite" >&2; exit 1; } +done +umask 077 +stage="$(mktemp -d "$config/.ari-setup.XXXXXXXX")" +cleanup() { + rm -f -- "$stage/ari.conf" "$stage/http.conf" "$stage/ari-secret" + rmdir -- "$stage" +} +trap cleanup EXIT +secret="$(openssl rand -hex 32)" +[[ "$secret" =~ ^[0-9a-f]{64}$ ]] || { echo 'could not generate ARI credential' >&2; exit 1; } +printf '%s' "$secret" >"$stage/ari-secret" +printf '[general]\nenabled = yes\npretty = no\n\n[go-sip-agent]\ntype = user\nread_only = no\npassword = %s\npassword_format = plain\n' "$secret" >"$stage/ari.conf" +printf '[general]\nenabled = yes\nbindaddr = 127.0.0.1\nbindport = 8088\n' >"$stage/http.conf" + +created=() +for name in ari-secret ari.conf http.conf; do + if ! ln -- "$stage/$name" "$config/$name"; then + for own_file in "${created[@]}"; do rm -f -- "$config/$own_file"; done + echo "cannot create $name without overwriting another file; fail-closed" >&2 + exit 1 + fi + created+=("$name") +done +echo 'private Asterisk ARI/HTTP configuration created; service not restarted' diff --git a/internal/config/asterisk_user_ari_test.go b/internal/config/asterisk_user_ari_test.go new file mode 100644 index 0000000..8677978 --- /dev/null +++ b/internal/config/asterisk_user_ari_test.go @@ -0,0 +1,55 @@ +package config_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestConfigureUserAsteriskARIPrivateAndNoOverwrite(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".config", "go-sip-asterisk") + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "asterisk.conf"), []byte("[directories]\n"), 0600); err != nil { + t.Fatal(err) + } + path := "../../deploys/cell/configure-asterisk-user-ari.sh" + run := func() (string, error) { + command := exec.Command("bash", path) + command.Env = append(os.Environ(), "HOME="+home) + output, err := command.CombinedOutput() + return string(output), err + } + if output, err := run(); err != nil { + t.Fatalf("private ARI configuration failed: %v %s", err, output) + } + for _, name := range []string{"ari.conf", "http.conf", "ari-secret"} { + info, err := os.Stat(filepath.Join(root, name)) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("ARI file %s must be private: info=%v err=%v", name, info, err) + } + } + password, err := os.ReadFile(filepath.Join(root, "ari-secret")) + if err != nil || len(password) != 64 { + t.Fatalf("ARI credential must be a generated 32-byte hex secret: err=%v length=%d", err, len(password)) + } + ari, err := os.ReadFile(filepath.Join(root, "ari.conf")) + if err != nil || !strings.Contains(string(ari), "password = "+string(password)) || !strings.Contains(string(ari), "read_only = no") { + t.Fatalf("ARI config did not bind generated credential: err=%v", err) + } + http, err := os.ReadFile(filepath.Join(root, "http.conf")) + if err != nil || !strings.Contains(string(http), "bindaddr = 127.0.0.1") || !strings.Contains(string(http), "bindport = 8088") { + t.Fatalf("HTTP must be explicitly restricted to the local Cell: err=%v", err) + } + if output, err := run(); err == nil || !strings.Contains(output, "already exists") { + t.Fatalf("repeat install must not overwrite private credentials: err=%v output=%s", err, output) + } + again, err := os.ReadFile(filepath.Join(root, "ari-secret")) + if err != nil || string(again) != string(password) { + t.Fatal("existing ARI credential was changed by a repeat install") + } +}