diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index fdc57ff..bd2e159 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -34,6 +34,8 @@ - 新增无实现代次字段的 Dispatcher 环境预检:必须显式给出规范 UUID v4 身份、只读 HTTP 地址及密钥、RabbitMQ 地址和 SQLite 路径;当前 Mock 仅接受本机 HTTP/MQ 目标,mixed/real 在任何资源操作前拒绝。预检不打开数据库或网络,缺失值不继承旧默认配置,错误不打印凭据。`go test ./internal/config -run '^TestLoadDispatcherEnvironment' -count=1` 通过;此预检尚未接入主 CLI,不能当作 P02/P03 完成。 +- Agent 增加独立的无代次环境预检:单 Agent/Cell 身份、会话与私有恢复路径、隔离 Mock 场景和已加载 SIP 测试事实、D gRPC 目标及 mTLS 文件/指纹均须显式提供;仅允许本机监听和本机 D 端点,mixed/real 在访问文件或网络前拒绝。缺失项不继承旧 `FromEnv` 默认值,凭据/地址不回显;`go test ./internal/config -run '^TestLoadAgentEnvironment' -count=1` 通过。此检查尚未接入主 `agent` 命令,也不能证明实际 TLS 或 Asterisk 加载。 + ## P03:HTTP 读取分批改造(未整体签收) - `contract.ValidateCurrent` 与 `configread` 按当前 Schema 读取 SIP、provider、task、quota 和 cursor 任务发现;严格检查数字 tenant_id、本 D 归属及不可变配置。provider 凭据原值只保留在内存快照,不写日志;Agent 参数中的显式 0/false 保真;无旧 Schema/旧配置回退。 diff --git a/internal/config/agent_runtime.go b/internal/config/agent_runtime.go new file mode 100644 index 0000000..4694d75 --- /dev/null +++ b/internal/config/agent_runtime.go @@ -0,0 +1,93 @@ +package config + +import ( + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// AgentEnvironment is deployment-owned and is inspected without opening any +// durable file, media adapter, credential or network connection. +type AgentEnvironment struct { + AgentID string + CellID string + Listen string + SessionPath string + RecoveryRoot string + DispatcherEndpoint string + DispatcherServerName string + CAFile string + CertFile string + KeyFile string + PeerFingerprints map[string]struct{} + MockScenarioFile string + MockAppliedSIPFile string +} + +func LoadAgentEnvironment(mode string) (AgentEnvironment, error) { + if mode != "mock" { + return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock mode") + } + get := func(name string) (string, error) { + value := os.Getenv(name) + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%s is required", name) + } + return value, nil + } + var settings AgentEnvironment + for _, field := range []struct { + name string + value *string + }{ + {"AGENT_ID", &settings.AgentID}, {"CELL_ID", &settings.CellID}, + {"AGENT_GRPC_LISTEN", &settings.Listen}, {"AGENT_SESSION_PATH", &settings.SessionPath}, + {"AGENT_RECOVERY_ROOT", &settings.RecoveryRoot}, + {"DISPATCHER_GRPC_ENDPOINT", &settings.DispatcherEndpoint}, + {"DISPATCHER_GRPC_SERVER_NAME", &settings.DispatcherServerName}, + {"MTLS_CA_FILE", &settings.CAFile}, {"MTLS_CERT_FILE", &settings.CertFile}, + {"MTLS_KEY_FILE", &settings.KeyFile}, + {"AGENT_MOCK_SCENARIO_FILE", &settings.MockScenarioFile}, + {"AGENT_MOCK_APPLIED_SIP_FILE", &settings.MockAppliedSIPFile}, + } { + value, err := get(field.name) + if err != nil { + return AgentEnvironment{}, err + } + *field.value = value + } + if !localGRPCAddress(settings.Listen, true) { + return AgentEnvironment{}, errors.New("AGENT_GRPC_LISTEN must be an isolated local Mock address") + } + if !localGRPCAddress(settings.DispatcherEndpoint, false) { + return AgentEnvironment{}, errors.New("DISPATCHER_GRPC_ENDPOINT must be an isolated local Mock address") + } + rawFingerprints, err := get("MTLS_PEER_CERT_FINGERPRINTS") + if err != nil { + return AgentEnvironment{}, err + } + settings.PeerFingerprints, err = ParseCertificateFingerprints(rawFingerprints) + if err != nil || len(settings.PeerFingerprints) == 0 { + return AgentEnvironment{}, errors.New("MTLS_PEER_CERT_FINGERPRINTS is invalid") + } + return settings, nil +} + +func localGRPCAddress(raw string, allowZeroPort bool) bool { + host, port, err := net.SplitHostPort(raw) + if err != nil { + return false + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 0 || portNumber > 65535 || (!allowZeroPort && portNumber == 0) { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/config/agent_runtime_test.go b/internal/config/agent_runtime_test.go new file mode 100644 index 0000000..a9fa782 --- /dev/null +++ b/internal/config/agent_runtime_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setAgentEnvironment(t *testing.T) string { + t.Helper() + root := t.TempDir() + state := filepath.Join(root, "session.json") + for name, value := range map[string]string{ + "AGENT_ID": "agent-mock", "CELL_ID": "cell-mock", "AGENT_GRPC_LISTEN": "127.0.0.1:0", + "AGENT_SESSION_PATH": state, "AGENT_RECOVERY_ROOT": filepath.Join(root, "recovery"), + "DISPATCHER_GRPC_ENDPOINT": "127.0.0.1:39443", "DISPATCHER_GRPC_SERVER_NAME": "dispatcher.local", + "MTLS_CA_FILE": filepath.Join(root, "ca.pem"), "MTLS_CERT_FILE": filepath.Join(root, "agent.pem"), + "MTLS_KEY_FILE": filepath.Join(root, "agent.key"), "MTLS_PEER_CERT_FINGERPRINTS": strings.Repeat("a", 64), + "AGENT_MOCK_SCENARIO_FILE": filepath.Join(root, "scenario.json"), + "AGENT_MOCK_APPLIED_SIP_FILE": filepath.Join(root, "applied-sip.json"), + } { + t.Setenv(name, value) + } + return state +} + +func TestLoadAgentEnvironmentRefusesNonMockBeforeResources(t *testing.T) { + for _, mode := range []string{"real", "mixed", ""} { + t.Run(mode, func(t *testing.T) { + state := setAgentEnvironment(t) + if _, err := LoadAgentEnvironment(mode); err == nil || !strings.Contains(err.Error(), "Mock") { + t.Fatalf("unapproved Agent mode was admitted: %v", err) + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatalf("mode refusal opened Agent state: %v", err) + } + }) + } +} + +func TestLoadAgentEnvironmentRequiresExplicitDeploymentValues(t *testing.T) { + for _, name := range []string{ + "AGENT_ID", "CELL_ID", "AGENT_GRPC_LISTEN", "AGENT_SESSION_PATH", "AGENT_RECOVERY_ROOT", + "DISPATCHER_GRPC_ENDPOINT", "DISPATCHER_GRPC_SERVER_NAME", "MTLS_CA_FILE", "MTLS_CERT_FILE", + "MTLS_KEY_FILE", "MTLS_PEER_CERT_FINGERPRINTS", "AGENT_MOCK_SCENARIO_FILE", "AGENT_MOCK_APPLIED_SIP_FILE", + } { + t.Run(name, func(t *testing.T) { + setAgentEnvironment(t) + t.Setenv(name, "") + if _, err := LoadAgentEnvironment("mock"); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("missing current Agent setting silently used a legacy default: %v", err) + } + }) + } +} + +func TestLoadAgentEnvironmentRequiresLocalMockEndpointsAndDoesNotOpenFiles(t *testing.T) { + state := setAgentEnvironment(t) + settings, err := LoadAgentEnvironment("mock") + if err != nil { + t.Fatal(err) + } + if settings.AgentID != "agent-mock" || settings.CellID != "cell-mock" || settings.Listen != "127.0.0.1:0" || settings.SessionPath != state || settings.DispatcherEndpoint != "127.0.0.1:39443" { + t.Fatal("explicit deployment identity or address was rewritten") + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatalf("Agent preflight opened durable state: %v", err) + } + for _, tc := range []struct{ name, value string }{ + {"AGENT_GRPC_LISTEN", "0.0.0.0:39444"}, + {"DISPATCHER_GRPC_ENDPOINT", "saas.example.invalid:443"}, + } { + t.Run(tc.name, func(t *testing.T) { + setAgentEnvironment(t) + t.Setenv(tc.name, tc.value) + if _, err := LoadAgentEnvironment("mock"); err == nil || !strings.Contains(err.Error(), tc.name) || strings.Contains(err.Error(), tc.value) { + t.Fatalf("external Mock address was accepted or leaked: %v", err) + } + }) + } +}