chore: initialize go-sip repository

This commit is contained in:
2026-09-21 08:56:04 +08:00
commit 643b11b21f
309 changed files with 40521 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
)
// AgentEndpoint is deployment-only Dispatcher configuration. It is not a SaaS
// command field and cannot be supplied by a tenant or a call task.
type AgentEndpoint struct {
AgentID string `json:"agent_id"`
CellID string `json:"cell_id"`
Address string `json:"address"`
ServerName string `json:"server_name"`
}
// LoadAgentEndpoints reads the Dispatcher-owned endpoint inventory. Strict JSON
// decoding prevents silently accepting misspelled authorization or identity
// fields.
func LoadAgentEndpoints(path string) ([]AgentEndpoint, error) {
if strings.TrimSpace(path) == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read Agent endpoint inventory: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var endpoints []AgentEndpoint
if err := decoder.Decode(&endpoints); err != nil {
return nil, fmt.Errorf("decode Agent endpoint inventory: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return nil, errors.New("Agent endpoint inventory contains trailing JSON")
}
return nil, fmt.Errorf("read Agent endpoint inventory trailer: %w", err)
}
if len(endpoints) == 0 {
return nil, errors.New("Agent endpoint inventory must not be empty")
}
seenAgents := make(map[string]struct{}, len(endpoints))
seenCells := make(map[string]struct{}, len(endpoints))
for index, endpoint := range endpoints {
if strings.TrimSpace(endpoint.AgentID) == "" || strings.TrimSpace(endpoint.CellID) == "" || strings.TrimSpace(endpoint.Address) == "" || strings.TrimSpace(endpoint.ServerName) == "" {
return nil, fmt.Errorf("Agent endpoint %d requires agent_id, cell_id, address, and server_name", index)
}
if _, exists := seenAgents[endpoint.AgentID]; exists {
return nil, fmt.Errorf("duplicate Agent ID %q", endpoint.AgentID)
}
if _, exists := seenCells[endpoint.CellID]; exists {
return nil, fmt.Errorf("duplicate Cell ID %q", endpoint.CellID)
}
seenAgents[endpoint.AgentID] = struct{}{}
seenCells[endpoint.CellID] = struct{}{}
}
return endpoints, nil
}
+65
View File
@@ -0,0 +1,65 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadAgentEndpointsValidatesStrictInventory(t *testing.T) {
path := filepath.Join(t.TempDir(), "agents.json")
valid := `[{"agent_id":"agent-a","cell_id":"cell-a","address":"127.0.0.1:19090","server_name":"agent.test"}]`
if err := os.WriteFile(path, []byte(valid), 0o600); err != nil {
t.Fatal(err)
}
endpoints, err := LoadAgentEndpoints(path)
if err != nil {
t.Fatal(err)
}
if len(endpoints) != 1 || endpoints[0].AgentID != "agent-a" {
t.Fatalf("unexpected endpoints: %+v", endpoints)
}
if err := os.WriteFile(path, []byte(`[{"agent_id":"agent-a","cell_id":"cell-a","address":"127.0.0.1:19090","server_name":"agent.test","role":"dispatcher"}]`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadAgentEndpoints(path); err == nil {
t.Fatal("expected unknown endpoint field to be rejected")
}
if err := os.WriteFile(path, []byte(`[{
"agent_id":"agent-a","cell_id":"cell-a","address":"127.0.0.1:19090","server_name":"agent.test"
},{
"agent_id":"agent-a","cell_id":"cell-b","address":"127.0.0.1:19091","server_name":"agent.test"
}]`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadAgentEndpoints(path); err == nil {
t.Fatal("expected duplicate Agent ID to be rejected")
}
}
func TestParseCertificateFingerprints(t *testing.T) {
fingerprint := "aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa"
parsed, err := ParseCertificateFingerprints(fingerprint + "," + fingerprint)
if err != nil {
t.Fatal(err)
}
if len(parsed) != 1 {
t.Fatalf("unexpected fingerprint count: %d", len(parsed))
}
if _, err := ParseCertificateFingerprints("not-a-fingerprint"); err == nil {
t.Fatal("expected invalid fingerprint to be rejected")
}
}
func TestConfigRequiresDispatcherMTLSForEndpointInventory(t *testing.T) {
cfg := Config{Mode: "mock", DBPath: ":memory:", AgentEndpointsFile: "agents.json"}
if err := cfg.Validate("dispatcher"); err == nil {
t.Fatal("expected Dispatcher mTLS requirement")
}
cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile = "ca.pem", "dispatcher.pem", "dispatcher.key"
if err := cfg.Validate("dispatcher"); err != nil {
t.Fatal(err)
}
}
+233
View File
@@ -0,0 +1,233 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Mode string
DBPath string
SpoolRoot string
RabbitURL string
Exchange string
OutboxBatch int
AgentID string
DispatcherID string
Version string
ControlListen string
ControlToken string
CellID string
GRPCListen string
DispatcherGRPCListen string
DispatcherGRPCEndpoint string
DispatcherGRPCServerName string
DispatcherGRPCAllowedAgentIDs string
MTLSCAFile string
MTLSCertFile string
MTLSKeyFile string
MTLSServerName string
MTLSPeerCertificateFingerprints string
OSSRegion string
OSSEndpoint string
OSSBucket string
OSSAccessKeyID string
OSSAccessKeySecret string
OSSKeyPrefix string
OSSGrantTTL time.Duration
OSSMaxAssetBytes int64
StaticArtifactPath string
AgentEndpointsFile string
CallLogPath string
CallLogPhoneKey string
ARIURL string
ARIWebsocketURL string
ARIApplication string
ARIUsername string
ARIPassword string
CallTarget string
CallTrunkID string
CallCallerID string
CallMediaBind string
CallMediaPort int
CallRecordingDirectory string
CallAISnapshotPath string
CallTenantID string
CallTenantKey string
CallTaskID string
CallTaskItemID string
CallExecutionID string
}
func FromEnv() Config {
return Config{
Mode: envOr("SIP_GO_AGENT_MODE", "mock"),
DBPath: envOr("DISPATCHER_DB", "dispatcher.db"),
SpoolRoot: envOr("AGENT_SPOOL", "./spool"),
RabbitURL: os.Getenv("RABBITMQ_URL"),
Exchange: envOr("RABBITMQ_EXCHANGE", "agent-call.commands.v1"),
OutboxBatch: 50,
AgentID: envOr("AGENT_ID", "agent-local"),
DispatcherID: envOr("DISPATCHER_ID", "dispatcher-local"),
Version: envOr("AGENT_VERSION", "dev"),
ControlListen: os.Getenv("DISPATCHER_CONTROL_LISTEN"),
ControlToken: os.Getenv("DISPATCHER_CONTROL_TOKEN"),
CellID: envOr("CELL_ID", "cell-local"),
GRPCListen: os.Getenv("AGENT_GRPC_LISTEN"),
DispatcherGRPCListen: os.Getenv("DISPATCHER_GRPC_LISTEN"),
DispatcherGRPCEndpoint: os.Getenv("DISPATCHER_GRPC_ENDPOINT"),
DispatcherGRPCServerName: envOr("DISPATCHER_GRPC_SERVER_NAME", os.Getenv("MTLS_SERVER_NAME")),
DispatcherGRPCAllowedAgentIDs: os.Getenv("DISPATCHER_ALLOWED_AGENT_IDS"),
MTLSCAFile: os.Getenv("MTLS_CA_FILE"),
MTLSCertFile: os.Getenv("MTLS_CERT_FILE"),
MTLSKeyFile: os.Getenv("MTLS_KEY_FILE"),
MTLSServerName: os.Getenv("MTLS_SERVER_NAME"),
MTLSPeerCertificateFingerprints: os.Getenv("MTLS_PEER_CERT_FINGERPRINTS"),
OSSRegion: os.Getenv("DISPATCHER_OSS_REGION"),
OSSEndpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"),
OSSBucket: os.Getenv("DISPATCHER_OSS_BUCKET"),
OSSAccessKeyID: envOrSecret("DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_ID_FILE"),
OSSAccessKeySecret: envOrSecret("DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_ACCESS_KEY_SECRET_FILE"),
OSSKeyPrefix: envOr("DISPATCHER_OSS_KEY_PREFIX", "agent-call/recordings"),
OSSGrantTTL: time.Duration(envInt("DISPATCHER_OSS_GRANT_TTL_SECONDS", 900)) * time.Second,
OSSMaxAssetBytes: int64(envInt("DISPATCHER_OSS_MAX_ASSET_BYTES", 64<<20)),
StaticArtifactPath: os.Getenv("AGENT_STATIC_ARTIFACT"),
AgentEndpointsFile: os.Getenv("DISPATCHER_AGENT_ENDPOINTS_FILE"),
CallLogPath: os.Getenv("AGENT_CALL_BUSINESS_LOG"),
CallLogPhoneKey: os.Getenv("AGENT_CALL_PHONE_LOG_KEY"),
ARIURL: envOr("ARI_URL", "http://127.0.0.1:8088/ari"),
ARIWebsocketURL: envOr("ARI_WS_URL", "ws://127.0.0.1:8088/ari/events"),
ARIApplication: envOr("ARI_APPLICATION", "agent-call"),
ARIUsername: os.Getenv("ARI_USERNAME"),
ARIPassword: os.Getenv("ARI_PASSWORD"),
CallTarget: os.Getenv("AGENT_CALL_TARGET"),
CallTrunkID: os.Getenv("AGENT_CALL_TRUNK_ID"),
CallCallerID: os.Getenv("AGENT_CALL_CALLER_ID"),
CallMediaBind: envOr("AGENT_CALL_MEDIA_BIND", "127.0.0.1"),
CallMediaPort: envInt("AGENT_CALL_MEDIA_PORT", 12000),
CallRecordingDirectory: envOr("AGENT_CALL_RECORDING_DIR", "./recordings"),
CallAISnapshotPath: os.Getenv("AGENT_CALL_AI_SNAPSHOT"),
CallTenantID: os.Getenv("AGENT_CALL_TENANT_ID"),
CallTenantKey: os.Getenv("AGENT_CALL_TENANT_KEY"),
CallTaskID: os.Getenv("AGENT_CALL_TASK_ID"),
CallTaskItemID: os.Getenv("AGENT_CALL_TASK_ITEM_ID"),
CallExecutionID: os.Getenv("AGENT_CALL_EXECUTION_ID"),
}
}
func (c Config) Validate(role string) error {
if c.Mode != "mock" && c.Mode != "mixed" && c.Mode != "real" {
return fmt.Errorf("unsupported mode %q", c.Mode)
}
if role == "dispatcher" && strings.TrimSpace(c.DBPath) == "" {
return errors.New("dispatcher DB path is required")
}
if role == "agent" && strings.TrimSpace(c.SpoolRoot) == "" {
return errors.New("agent spool root is required")
}
if role == "agent" && strings.TrimSpace(c.CallLogPath) != "" && strings.TrimSpace(c.CallLogPhoneKey) == "" {
return errors.New("AGENT_CALL_PHONE_LOG_KEY is required when AGENT_CALL_BUSINESS_LOG is set")
}
if role == "agent" && strings.TrimSpace(c.CallLogPhoneKey) != "" && len([]byte(c.CallLogPhoneKey)) < 16 {
return errors.New("AGENT_CALL_PHONE_LOG_KEY must contain at least 16 bytes")
}
if role == "agent" && strings.TrimSpace(c.GRPCListen) != "" {
for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required when AGENT_GRPC_LISTEN is enabled", name)
}
}
}
if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.GRPCListen) == "" {
return errors.New("real agent mode requires AGENT_GRPC_LISTEN")
}
if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.StaticArtifactPath) == "" {
return errors.New("real agent mode requires AGENT_STATIC_ARTIFACT")
}
if role == "agent" && strings.TrimSpace(c.MTLSPeerCertificateFingerprints) != "" {
if _, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints); err != nil {
return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err)
}
}
if role == "dispatcher" && strings.TrimSpace(c.ControlListen) != "" && strings.TrimSpace(c.ControlToken) == "" {
return errors.New("control HTTP requires DISPATCHER_CONTROL_TOKEN")
}
if role == "agent" && strings.TrimSpace(c.DispatcherGRPCEndpoint) != "" {
for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required when DISPATCHER_GRPC_ENDPOINT is enabled", name)
}
}
}
if role == "dispatcher" && strings.TrimSpace(c.DispatcherGRPCListen) != "" {
for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required when DISPATCHER_GRPC_LISTEN is enabled", name)
}
}
for name, value := range map[string]string{"DISPATCHER_OSS_REGION": c.OSSRegion, "DISPATCHER_OSS_ENDPOINT": c.OSSEndpoint, "DISPATCHER_OSS_BUCKET": c.OSSBucket, "DISPATCHER_OSS_ACCESS_KEY_ID": c.OSSAccessKeyID, "DISPATCHER_OSS_ACCESS_KEY_SECRET": c.OSSAccessKeySecret} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required when Dispatcher gRPC is enabled", name)
}
}
if strings.TrimSpace(c.DispatcherGRPCAllowedAgentIDs) == "" {
return errors.New("DISPATCHER_ALLOWED_AGENT_IDS is required when Dispatcher gRPC is enabled")
}
fingerprints, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints)
if err != nil {
return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err)
}
if len(fingerprints) == 0 {
return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled")
}
}
if role == "dispatcher" && strings.TrimSpace(c.AgentEndpointsFile) != "" {
for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required when DISPATCHER_AGENT_ENDPOINTS_FILE is enabled", name)
}
}
}
if c.Mode == "real" && strings.TrimSpace(c.RabbitURL) == "" && role == "dispatcher" {
return errors.New("real dispatcher mode requires RABBITMQ_URL")
}
return nil
}
func envOr(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envOrSecret(valueKey, fileKey string) string {
if value := strings.TrimSpace(os.Getenv(valueKey)); value != "" {
return value
}
path := strings.TrimSpace(os.Getenv(fileKey))
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func envInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
+85
View File
@@ -0,0 +1,85 @@
package config
import "testing"
func TestConfigRejectsRealDispatcherWithoutBroker(t *testing.T) {
c := FromEnv()
c.Mode, c.RabbitURL, c.DBPath = "real", "", ":memory:"
if err := c.Validate("dispatcher"); err == nil {
t.Fatal("expected real-mode broker requirement")
}
}
func TestConfigAcceptsMockAgent(t *testing.T) {
c := Config{Mode: "mock", SpoolRoot: t.TempDir()}
if err := c.Validate("agent"); err != nil {
t.Fatal(err)
}
}
func TestConfigLoadsUnifiedDispatcherGRPCSettings(t *testing.T) {
t.Setenv("DISPATCHER_GRPC_LISTEN", "127.0.0.1:19443")
t.Setenv("DISPATCHER_GRPC_ENDPOINT", "dispatcher.test:19443")
t.Setenv("DISPATCHER_GRPC_SERVER_NAME", "dispatcher.test")
t.Setenv("DISPATCHER_ALLOWED_AGENT_IDS", "agent-cell-a,agent-cell-b")
c := FromEnv()
if c.DispatcherGRPCListen != "127.0.0.1:19443" || c.DispatcherGRPCEndpoint != "dispatcher.test:19443" || c.DispatcherGRPCServerName != "dispatcher.test" || c.DispatcherGRPCAllowedAgentIDs != "agent-cell-a,agent-cell-b" {
t.Fatalf("unified Dispatcher gRPC settings were not loaded: %+v", c)
}
}
func TestConfigRequiresPhoneLogPair(t *testing.T) {
c := Config{Mode: "mock", SpoolRoot: t.TempDir(), CallLogPath: "calls.jsonl"}
if err := c.Validate("agent"); err == nil {
t.Fatal("expected phone log key requirement")
}
c.CallLogPhoneKey = "0123456789abcdef"
if err := c.Validate("agent"); err != nil {
t.Fatal(err)
}
c.CallLogPath = ""
if err := c.Validate("agent"); err != nil {
t.Fatalf("key-only configuration should use spool fallback: %v", err)
}
c.CallLogPhoneKey = "short"
if err := c.Validate("agent"); err == nil {
t.Fatal("expected phone log key length requirement")
}
}
func TestConfigRequiresAgentTLSFilesWhenRPCIsEnabled(t *testing.T) {
c := Config{Mode: "mock", SpoolRoot: t.TempDir(), GRPCListen: "127.0.0.1:19090"}
if err := c.Validate("agent"); err == nil {
t.Fatal("expected mTLS file requirement")
}
c.MTLSCAFile, c.MTLSCertFile, c.MTLSKeyFile = "ca.pem", "agent.pem", "agent.key"
if err := c.Validate("agent"); err != nil {
t.Fatal(err)
}
c.Mode, c.GRPCListen = "real", ""
if err := c.Validate("agent"); err == nil {
t.Fatal("expected real agent listener requirement")
}
}
func TestConfigRequiresStaticArtifactForRealAgent(t *testing.T) {
c := Config{Mode: "real", SpoolRoot: t.TempDir(), GRPCListen: "127.0.0.1:19090", MTLSCAFile: "ca.pem", MTLSCertFile: "agent.pem", MTLSKeyFile: "agent.key"}
if err := c.Validate("agent"); err == nil {
t.Fatal("expected static artifact requirement")
}
c.StaticArtifactPath = "artifact.json"
if err := c.Validate("agent"); err != nil {
t.Fatal(err)
}
}
func TestConfigRequiresControlTokenWhenHTTPIsEnabled(t *testing.T) {
c := Config{Mode: "mock", DBPath: ":memory:", ControlListen: "127.0.0.1:8081"}
if err := c.Validate("dispatcher"); err == nil {
t.Fatal("expected control token requirement")
}
c.ControlToken = "test-token"
if err := c.Validate("dispatcher"); err != nil {
t.Fatal(err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package config
import (
"encoding/hex"
"fmt"
"strings"
)
// ParseCertificateFingerprints parses a deployment-owned comma-separated SHA-256
// leaf fingerprint allowlist. Colons are accepted for operator convenience but
// normalized away before comparison.
func ParseCertificateFingerprints(raw string) (map[string]struct{}, error) {
result := make(map[string]struct{})
for _, item := range strings.Split(raw, ",") {
fingerprint := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(item), ":", ""))
if fingerprint == "" {
continue
}
if len(fingerprint) != 64 {
return nil, fmt.Errorf("mTLS peer certificate fingerprint must be 32 bytes: %q", item)
}
if _, err := hex.DecodeString(fingerprint); err != nil {
return nil, fmt.Errorf("invalid mTLS peer certificate fingerprint %q: %w", item, err)
}
result[fingerprint] = struct{}{}
}
return result, nil
}