From 8f029bcf7a1beeabebb823e64b3071fbd8897bfb Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 4 Oct 2026 13:16:46 +0800 Subject: [PATCH] feat(agent): verify native ARI identity before real execution --- docs/evidence/sip-user-service-nonprod.md | 4 ++ internal/asterisk/ari.go | 65 +++++++++++++++++++++++ internal/asterisk/ari_integration_test.go | 20 +++++++ internal/asterisk/ari_test.go | 30 +++++++++++ 4 files changed, 119 insertions(+) create mode 100644 internal/asterisk/ari.go create mode 100644 internal/asterisk/ari_integration_test.go create mode 100644 internal/asterisk/ari_test.go diff --git a/docs/evidence/sip-user-service-nonprod.md b/docs/evidence/sip-user-service-nonprod.md index f7d5c84..a958027 100644 --- a/docs/evidence/sip-user-service-nonprod.md +++ b/docs/evidence/sip-user-service-nonprod.md @@ -35,6 +35,10 @@ The test host's address and raw logs are omitted from committed evidence. - After read-only confirmation of **zero active calls and channels**, the committed and locally tested `configure-asterisk-user-ari.sh` (`1d12007`) created only the `rogee` user's private `ari.conf`, `http.conf` and `ari-secret`, all mode `0600`; no existing file was overwritten and no credential was printed or committed. HTTP binds only `127.0.0.1:8088`. The user-level Asterisk service was then explicitly restarted and reported **enabled+active** with a live CLI; anonymous ARI status returned **401**, and a read-only authenticated ARI information request returned **200**. No outbound call, registration or media capture was performed. - Very short independent SSH probes had observed the service active before its control socket appeared. In a sustained SSH session, its PID stayed stable and the socket and CLI became available after startup; this was a session/startup observation, not evidence of a broken Asterisk binary. Lingering is **still disabled**: availability after logout or reboot remains unverified. Real Agent ARI use, signed task/provider snapshots, carrier requirements and capture-first host validation have **not** been accepted. +## Native Agent ARI SDK readback — 2026-10-04 + +- On the same pinned test host, an explicitly opt-in, CGO-disabled Go test read the owner-only `ari-secret` **in memory** (no value logged) and used the existing `github.com/CyCoreSystems/ari/v5/client/native` client against the loopback ARI endpoint. `native.Connect` and the authenticated read-only Asterisk status request both passed. The test binary was temporary and removed after the check; no channel was created, no call originated, no RTP/audio was captured and no OSS or AI request was issued. `internal/asterisk/ari.go` fails closed for missing, non-regular, non-`0600`, malformed or mismatched credentials. SDK readback does **not** establish real Agent execution, Stasis/media handling, recording or carrier acceptance. + ## Still required before a real call or production acceptance 1. Verify the effect of endpoint updates **during an active authorized call** separately; SIP-only does not enable business dialing. Unsupported authentication/REGISTER and transport changes must continue to fail closed, and provider-side authentication, registration, routing and capacity remain unverified. diff --git a/internal/asterisk/ari.go b/internal/asterisk/ari.go new file mode 100644 index 0000000..fac94b3 --- /dev/null +++ b/internal/asterisk/ari.go @@ -0,0 +1,65 @@ +package asterisk + +import ( + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/CyCoreSystems/ari/v5" + "github.com/CyCoreSystems/ari/v5/client/native" +) + +// ariOptions uses the credential issued to the Agent on the same Cell as +// native Asterisk. It never reads a historical local test credential. +func (l Loader) ariOptions() (*native.Options, error) { + if l.ConfigDir == "" { + return nil, errors.New("native Asterisk configuration directory required") + } + path := filepath.Join(l.ConfigDir, "ari-secret") + info, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("inspect issued Agent ARI credential: %T", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { + return nil, errors.New("issued Agent ARI credential must be a private regular file (0600)") + } + secret, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read issued Agent ARI credential: %T", err) + } + if len(secret) != 64 { + return nil, errors.New("issued Agent ARI credential has an invalid length") + } + if _, err := hex.DecodeString(string(secret)); err != nil { + return nil, errors.New("issued Agent ARI credential has an invalid format") + } + return &native.Options{ + URL: "http://127.0.0.1:8088/ari", WebsocketURL: "ws://127.0.0.1:8088/ari/events", + Application: "go-sip-agent", Username: "go-sip-agent", Password: string(secret), + }, nil +} + +// OpenARI proves that the native server accepts this Agent credential without +// creating a channel or dialing. The caller owns and must close the client. +func (l Loader) OpenARI() (ari.Client, error) { + options, err := l.ariOptions() + if err != nil { + return nil, err + } + client, err := native.Connect(options) + if err != nil { + return nil, fmt.Errorf("connect native Agent ARI: %T", err) + } + info, err := client.Asterisk().Info(nil) + if err != nil { + client.Close() + return nil, fmt.Errorf("read native Agent ARI status: %T", err) + } + if info == nil { + client.Close() + return nil, errors.New("native Agent ARI returned an empty status") + } + return client, nil +} diff --git a/internal/asterisk/ari_integration_test.go b/internal/asterisk/ari_integration_test.go new file mode 100644 index 0000000..8687927 --- /dev/null +++ b/internal/asterisk/ari_integration_test.go @@ -0,0 +1,20 @@ +package asterisk + +import ( + "os" + "testing" +) + +// Explicitly opt in on the Cell to check real ARI credentials without +// creating a channel, issuing a dial or reading private audio. +func TestAgentARIReadOnlyIntegration(t *testing.T) { + configDir := os.Getenv("AGENT_ARI_READONLY_TEST_CONFIG_DIR") + if configDir == "" { + t.Skip("real Agent ARI readback requires explicit Cell config path") + } + client, err := (Loader{ConfigDir: configDir}).OpenARI() + if err != nil { + t.Fatal(err) + } + defer client.Close() +} diff --git a/internal/asterisk/ari_test.go b/internal/asterisk/ari_test.go new file mode 100644 index 0000000..4aea19c --- /dev/null +++ b/internal/asterisk/ari_test.go @@ -0,0 +1,30 @@ +package asterisk + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAgentARIOptionsRequirePrivateIssuedCredential(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ari-secret") + secret := strings.Repeat("a", 64) + if err := os.WriteFile(path, []byte(secret), 0600); err != nil { + t.Fatal(err) + } + options, err := (Loader{ConfigDir: dir}).ariOptions() + if err != nil { + t.Fatal(err) + } + if options.URL != "http://127.0.0.1:8088/ari" || options.WebsocketURL != "ws://127.0.0.1:8088/ari/events" || options.Application != "go-sip-agent" || options.Username != "go-sip-agent" || options.Password != secret { + t.Fatal("ARI must bind the issued user identity and loopback endpoint") + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if _, err := (Loader{ConfigDir: dir}).ariOptions(); err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("public credential must fail without leaking its value: %v", err) + } +}