From 6f3404bd09a852f4d3c5d3a6344b5e641846c8e3 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 03:23:54 +0800 Subject: [PATCH] Validate Mock Dispatcher startup environment without defaults --- .../saas-dispatcher-implementation.md | 2 + internal/config/runtime.go | 84 +++++++++++++++++++ internal/config/runtime_test.go | 82 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 internal/config/runtime.go create mode 100644 internal/config/runtime_test.go diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 4647e01..283ad15 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -32,6 +32,8 @@ - 原 Buf STANDARD 的 `PACKAGE_VERSION_SUFFIX` 与已批准的无代次内部包名冲突;`buf.yaml` 仅对这一条规则作例外,其余 STANDARD 规则保持。隔离安装 Buf v1.50.0 / protoc-gen-go v1.36.12 / protoc-gen-go-grpc v1.5.1 于 `/tmp/sip-go-agent-tools/bin`,未改应用依赖。 - `PATH=/tmp/sip-go-agent-tools/bin:$PATH sh scripts/check-proto.sh` 通过(lint/build/generate/新包测试/7 文件清单 hash);`git diff --check`、`go test ./internal/rpc ./internal/agent ./internal/dispatcher ./cmd/sip-go-agent -count=1` 通过。其他旧实现/契约入口、文件名碰撞和迁移数据安全仍待 P02/P07,**不能据此称全仓已无代次或整体完成**。 +- 新增无实现代次字段的 Dispatcher 环境预检:必须显式给出规范 UUID v4 身份、只读 HTTP 地址及密钥、RabbitMQ 地址和 SQLite 路径;当前 Mock 仅接受本机 HTTP/MQ 目标,mixed/real 在任何资源操作前拒绝。预检不打开数据库或网络,缺失值不继承旧默认配置,错误不打印凭据。`go test ./internal/config -run '^TestLoadDispatcherEnvironment' -count=1` 通过;此预检尚未接入主 CLI,不能当作 P02/P03 完成。 + ## P03:HTTP 读取分批改造(未整体签收) - `contract.ValidateCurrent` 与 `configread` 按当前 Schema 读取 SIP、provider、task、quota 和 cursor 任务发现;严格检查数字 tenant_id、本 D 归属及不可变配置。provider 凭据原值只保留在内存快照,不写日志;Agent 参数中的显式 0/false 保真;无旧 Schema/旧配置回退。 diff --git a/internal/config/runtime.go b/internal/config/runtime.go new file mode 100644 index 0000000..f22a71d --- /dev/null +++ b/internal/config/runtime.go @@ -0,0 +1,84 @@ +package config + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "strings" + + "git.ipao.vip/rogee/go-sip/internal/tenant" +) + +// DispatcherEnvironment contains only deployment-owned startup values. The +// SaaS read secret is never a task/AI setting and must not be logged. +type DispatcherEnvironment struct { + DispatcherID string + SecretKey string + SaaSBaseURL string + RabbitMQURL string + SQLitePath string +} + +// LoadDispatcherEnvironment is pure inspection: it opens no database, queue or +// network connection. The current executable accepts only isolated Mock mode. +func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) { + if mode != "mock" { + return DispatcherEnvironment{}, errors.New("Dispatcher 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 DispatcherEnvironment + var err error + if settings.DispatcherID, err = get("DISPATCHER_ID"); err != nil { + return DispatcherEnvironment{}, err + } + if tenant.ValidateDispatcherID(settings.DispatcherID) != nil { + return DispatcherEnvironment{}, errors.New("DISPATCHER_ID must be a canonical UUID v4") + } + if settings.SecretKey, err = get("DISPATCHER_SECRET_KEY"); err != nil { + return DispatcherEnvironment{}, err + } + if settings.SaaSBaseURL, err = get("SAAS_BASE_URL"); err != nil { + return DispatcherEnvironment{}, err + } + if !localEndpoint(settings.SaaSBaseURL, "http", "https") { + return DispatcherEnvironment{}, errors.New("SAAS_BASE_URL must name a local Mock HTTP endpoint") + } + if settings.RabbitMQURL, err = get("RABBITMQ_URL"); err != nil { + return DispatcherEnvironment{}, err + } + if !localEndpoint(settings.RabbitMQURL, "amqp", "amqps") { + return DispatcherEnvironment{}, errors.New("RABBITMQ_URL must name a local Mock AMQP endpoint") + } + if settings.SQLitePath, err = get("DISPATCHER_SQLITE_PATH"); err != nil { + return DispatcherEnvironment{}, err + } + return settings, nil +} + +func localEndpoint(raw string, schemes ...string) bool { + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" || parsed.Opaque != "" || parsed.Fragment != "" || parsed.RawQuery != "" { + return false + } + validScheme := false + for _, scheme := range schemes { + validScheme = validScheme || parsed.Scheme == scheme + } + if !validScheme { + return false + } + host := parsed.Hostname() + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go new file mode 100644 index 0000000..171a837 --- /dev/null +++ b/internal/config/runtime_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const dispatcherFixtureID = "c046b893-8628-4589-ae50-619d049248a6" + +func setDispatcherEnvironment(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "dispatcher.db") + t.Setenv("DISPATCHER_ID", dispatcherFixtureID) + t.Setenv("DISPATCHER_SECRET_KEY", "synthetic-placeholder-not-a-credential") + t.Setenv("SAAS_BASE_URL", "http://127.0.0.1:8080") + t.Setenv("RABBITMQ_URL", "amqp://127.0.0.1:5672/%2Fmock") + t.Setenv("DISPATCHER_SQLITE_PATH", path) + return path +} + +func TestLoadDispatcherEnvironmentRefusesNonMockBeforeResources(t *testing.T) { + for _, mode := range []string{"real", "mixed", ""} { + t.Run(mode, func(t *testing.T) { + path := setDispatcherEnvironment(t) + if _, err := LoadDispatcherEnvironment(mode); err == nil || !strings.Contains(err.Error(), "Mock") { + t.Fatalf("unapproved mode was admitted: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("mode rejection opened a database: %v", err) + } + }) + } +} + +func TestLoadDispatcherEnvironmentRequiresExplicitIdentityAndNoFallback(t *testing.T) { + for _, name := range []string{"DISPATCHER_ID", "DISPATCHER_SECRET_KEY", "SAAS_BASE_URL", "RABBITMQ_URL", "DISPATCHER_SQLITE_PATH"} { + t.Run(name, func(t *testing.T) { + setDispatcherEnvironment(t) + t.Setenv(name, "") + if _, err := LoadDispatcherEnvironment("mock"); err == nil || !strings.Contains(err.Error(), name) { + t.Fatalf("missing current config was silently defaulted: %v", err) + } + }) + } + setDispatcherEnvironment(t) + t.Setenv("DISPATCHER_ID", "local-dispatcher") + if _, err := LoadDispatcherEnvironment("mock"); err == nil || !strings.Contains(err.Error(), "DISPATCHER_ID") { + t.Fatalf("non-UUID dispatcher identity was admitted: %v", err) + } +} + +func TestLoadDispatcherEnvironmentKeepsMockTransportsLocalAndErrorsRedacted(t *testing.T) { + for _, tc := range []struct{ name, value string }{ + {"SAAS_BASE_URL", "https://saas.example.invalid"}, + {"RABBITMQ_URL", "amqps://mq.example.invalid:5671/%2F"}, + } { + t.Run(tc.name, func(t *testing.T) { + setDispatcherEnvironment(t) + t.Setenv(tc.name, tc.value) + _, err := LoadDispatcherEnvironment("mock") + if err == nil || !strings.Contains(err.Error(), tc.name) || strings.Contains(err.Error(), "synthetic-placeholder-not-a-credential") || strings.Contains(err.Error(), tc.value) { + t.Fatalf("remote Mock endpoint or credential disclosure was allowed: %v", err) + } + }) + } +} + +func TestLoadDispatcherEnvironmentPreservesExplicitValuesWithoutOpeningSQLite(t *testing.T) { + path := setDispatcherEnvironment(t) + settings, err := LoadDispatcherEnvironment("mock") + if err != nil { + t.Fatal(err) + } + if settings.DispatcherID != dispatcherFixtureID || settings.SecretKey != "synthetic-placeholder-not-a-credential" || settings.SaaSBaseURL != "http://127.0.0.1:8080" || settings.RabbitMQURL != "amqp://127.0.0.1:5672/%2Fmock" || settings.SQLitePath != path { + t.Fatal("explicit current environment values were changed") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("config inspection opened the durable business database: %v", err) + } +}