chore: initialize go-sip repository
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user