feat(agent): verify native ARI identity before real execution

This commit is contained in:
2026-10-04 13:16:46 +08:00
parent 34f8dd91fa
commit 8f029bcf7a
4 changed files with 119 additions and 0 deletions
@@ -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.
+65
View File
@@ -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
}
+20
View File
@@ -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()
}
+30
View File
@@ -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)
}
}