From 67cb438e2ec3ef1d147b0f480dd35915f503c636 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 09:18:30 +0800 Subject: [PATCH] Require approved CA for local Mock HTTPS --- cmd/sip-go-agent/current_agent_setup.go | 20 +++++++-- cmd/sip-go-agent/current_agent_setup_test.go | 44 ++++++++++++++++++- .../current_dispatcher_command.go | 5 ++- .../saas-dispatcher-implementation.md | 1 + 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/cmd/sip-go-agent/current_agent_setup.go b/cmd/sip-go-agent/current_agent_setup.go index 4d12e1a..b8e96a1 100644 --- a/cmd/sip-go-agent/current_agent_setup.go +++ b/cmd/sip-go-agent/current_agent_setup.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "errors" "log" "maps" @@ -61,7 +63,14 @@ func newCurrentAgentServer(ctx context.Context, settings config.AgentEnvironment for index := range scenario.Script.Turns { scenario.Script.Turns[index].ReplyPCM16 = bytes.Clone(scenario.Script.Turns[index].ReplyPCM16) } - uploadHTTP := localMockHTTPClient() + ca, err := readAgentPEM("MTLS_CA_FILE", settings.CAFile) + if err != nil { + return nil, err + } + uploadHTTP, err := localMockHTTPClient(ca) + if err != nil { + return nil, err + } var handler *rpc.Server worker := &rpc.ApprovedCallWorker{ Lifecycle: ctx, @@ -109,9 +118,14 @@ func newCurrentAgentServer(ctx context.Context, settings config.AgentEnvironment // The isolated Mock uploader may reach localhost only, even if a grant or // redirect unexpectedly names a real OSS endpoint. It never logs signed URLs. -func localMockHTTPClient() *http.Client { +func localMockHTTPClient(trustPEM []byte) (*http.Client, error) { + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(trustPEM) { + return nil, errors.New("Mock HTTPS requires an approved trust bundle") + } transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = nil + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots} transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { host, _, err := net.SplitHostPort(address) if err != nil { @@ -123,5 +137,5 @@ func localMockHTTPClient() *http.Client { } return (&net.Dialer{}).DialContext(ctx, network, address) } - return &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + return &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, nil } diff --git a/cmd/sip-go-agent/current_agent_setup_test.go b/cmd/sip-go-agent/current_agent_setup_test.go index 49f8edf..f168d5a 100644 --- a/cmd/sip-go-agent/current_agent_setup_test.go +++ b/cmd/sip-go-agent/current_agent_setup_test.go @@ -3,6 +3,9 @@ package main import ( "bytes" "context" + "crypto/x509" + "encoding/pem" + "errors" "net/http" "net/http/httptest" "os" @@ -32,6 +35,10 @@ func currentAgentSetupFixture(t *testing.T) (config.AgentEnvironment, approvedMo CAFile: filepath.Join(root, "ca.pem"), CertFile: filepath.Join(root, "agent.pem"), KeyFile: filepath.Join(root, "agent.key"), PeerFingerprints: map[string]struct{}{strings.Repeat("a", 64): {}}, } + ca, _, _, _, _, _ := localCommandCertificates(t) + if err := os.WriteFile(settings.CAFile, ca, 0600); err != nil { + t.Fatal(err) + } scenario := approvedMockScenario{ InboundPCM16: bytes.Repeat([]byte{1, 0}, 1600), Script: ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "synthetic ASR fixture"}}}, @@ -98,8 +105,43 @@ func TestNewCurrentAgentServerRefusesUnsafeAdaptersBeforeResources(t *testing.T) } } +func TestLocalMockHTTPClientTrustsOnlyApprovedLocalHTTPSCertificate(t *testing.T) { + local := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer local.Close() + trustedPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: local.Certificate().Raw}) + trusted, err := localMockHTTPClient(trustedPEM) + if err != nil { + t.Fatal(err) + } + response, err := trusted.Get(local.URL) + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("verified local HTTPS was refused: response=%v err=%v", response, err) + } + _ = response.Body.Close() + + unrelatedCA, _, _, _, _, _ := localCommandCertificates(t) + untrusted, err := localMockHTTPClient(unrelatedCA) + if err != nil { + t.Fatal(err) + } + response, err = untrusted.Get(local.URL) + var unknownAuthority x509.UnknownAuthorityError + if response != nil || !errors.As(err, &unknownAuthority) { + t.Fatalf("unknown local HTTPS certificate was admitted: response=%v err=%v", response, err) + } + if client, err := localMockHTTPClient([]byte("invalid trust bundle")); err == nil || client != nil { + t.Fatalf("invalid trust bundle was accepted: %v", err) + } +} + func TestLocalMockHTTPClientRefusesExternalAndRedirectedTargets(t *testing.T) { - client := localMockHTTPClient() + ca, _, _, _, _, _ := localCommandCertificates(t) + client, err := localMockHTTPClient(ca) + if err != nil { + t.Fatal(err) + } if response, err := client.Get("https://oss.example.invalid/approved"); err == nil || response != nil || !strings.Contains(err.Error(), "not local") { t.Fatalf("Mock uploader contacted an external target: %v", err) } diff --git a/cmd/sip-go-agent/current_dispatcher_command.go b/cmd/sip-go-agent/current_dispatcher_command.go index c483c96..a376e92 100644 --- a/cmd/sip-go-agent/current_dispatcher_command.go +++ b/cmd/sip-go-agent/current_dispatcher_command.go @@ -69,7 +69,10 @@ func runCurrentDispatcher(ctx context.Context, mode string) (result error) { if err != nil { return errors.New("Dispatcher mTLS Agent certificate configuration is invalid") } - httpClient := localMockHTTPClient() + httpClient, err := localMockHTTPClient(ca) + if err != nil { + return err + } httpClient.Timeout = 10 * time.Second _, reader, err := dispatcherConfigurationClient(mode, httpClient) if err != nil { diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 01e37b0..2107305 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -89,6 +89,7 @@ - 最终结果隔离组件:`ApprovedExecution` 已保留获批任务的 `caller_profile_id`;`FinalResultPayload` 只从批准的任务身份、确认时间及实际收到的用户媒体窗口生成当前唯一结果;最终 ASR 文本与字面关键词拒联写入完整转写,未播放的助手候选内容不冒充转写,未知 SIP 响应码保留 `null`,无录音保留 `{}`。单元测试直接核验当前 MQ Schema、字段缺失/乱序时窗与无应答负例;开场白发送失败不得计入已播放音频。此组件已接入 Agent 合成 runner,但未证明真实 RTP 转写时间精度。 - 本地隔离链路:`RunApprovedCall` 显式使用合成 `ApprovedMockPipeline`,让共享批准通话流程按 ASR-only 限制消费实际读出的 PCM Mock 媒体帧;`RecordingSession` 生成内存 WAV,`FinalResultPayload` 仅用最终模拟识别及实测采集时窗构造结果。Agent 经双向 TLS gRPC 向 Dispatcher 确认结束、领取原始资产签名授权,再对本地 HTTPS OSS Mock 单次 PUT;Dispatcher 将原上传事实与唯一最终结果 outbox 同事务提交。测试确认一次 PUT、一次结果、无业务文件,使用官方 SDK 生成的路径;Mock ASR 与 OSS 服务不验证真实供应商协议或签名。这不是主入口执行、RabbitMQ 投递或外部验收。 - `ApprovedRecordedMockCall` 将显式批准的 ASR-only AI 快照、合成 PCM 媒体、每通话独立脚本、实际采集的有界 WAV、用户最终识别时窗和 `RecordingDelivery` 组合到一个可供 Agent 工人调用的 Mock runner;隔离测试验证先结束事实、一次本地 OSS PUT、唯一最终结果及正常路径无业务文件。无实际读入媒体时明确报告失败并以空录音、生成失败原因收口,不伪造上传或转写;缺失每通话交付器、交付器与签发 D/数字租户/事件不一致、私有恢复目录并非 `0700` 或模拟脚本不满足已批准 AI 时,均在发外呼接受回执前拒绝。此 runner 已接到隔离 Mock 主 CLI 的 `ApprovedCallWorker`,但还没有从主进程完成 D→A 执行、Agent→D 录音与 MQ 出站的端到端联测;现有 OSS/RPC 测试均为隔离替身,不能代表真实线路或供应商通过。 +- 本机 HTTPS Mock 信任:Agent 上传及 Dispatcher 配置读取使用现有 mTLS CA 信任束校验证书,不放宽主机名或证书验证,仍拒绝非本机目标及跨域重定向。测试确认已批准的本机 HTTPS 证书可访问,未知证书与无效信任束均失败;未使用 `InsecureSkipVerify`。这不是对真实 OSS 的验证。 - 已验证:`go test ./... -count=1`、`go test -race ./... -count=1`、`go vet ./...`、`go build ./...`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`git diff --check`。主 Agent 入口已组装隔离合成媒体和录音交付,但尚未通过主进程 D↔A 会话/执行/录音、实际上传事实与最终结果交付、MQ 发布及重启恢复的整体联测,不能宣称 P06 通过。 ## 验收台账