diff --git a/monkeyai/.env.example b/monkeyai/.env.example index 706045f10..b5240e503 100644 --- a/monkeyai/.env.example +++ b/monkeyai/.env.example @@ -29,6 +29,12 @@ RUSTFS_SECRET_KEY=change-this-rustfs-admin-secret # MONKEYAI_S3_REGION=us-east-1 # MONKEYAI_MCP_ALLOWED_CIDRS=10.0.0.0/8,192.168.0.0/16 +# 后端访问外部 HTTP(S) 服务时使用的代理。仅 HTTPS 目标可单独设置 HTTPS_PROXY。 +# HTTP_PROXY=http://proxy.example.com:7890 +# HTTPS_PROXY=http://proxy.example.com:7890 +# 内部服务必须直连;自定义时保留 db、rustfs 和本机地址。 +# MONKEYAI_NO_PROXY=db,rustfs,localhost,127.0.0.1,::1 + # 远程计费使用服务端 mTLS。服务地址、应用 ID 和证书须由百智云分配后填写。 # 推荐通过后台「计费设置」上传证书;以下为兼容部署配置,后台保存值优先。 # BAIZHIYUN_BASE_URL= diff --git a/monkeyai/README.md b/monkeyai/README.md index bdca1df38..425c74160 100644 --- a/monkeyai/README.md +++ b/monkeyai/README.md @@ -15,6 +15,18 @@ docker compose up --build `MONKEYAI_PUBLIC_URL` 是管理页面和 API 共用的对外访问地址,也用于 OAuth 回调、元数据及登录后的页面跳转。若修改 `MONKEYAI_ADMIN_PORT`,还需将该地址改为浏览器实际访问的地址;生产环境应使用 HTTPS。 +## 后端出站代理 + +在 `.env` 中设置 `HTTP_PROXY`、`HTTPS_PROXY`(例如 `http://proxy.example.com:7890`),Compose 会将其传入 backend;不配置时保持直连。修改后运行 `docker compose up -d backend` 重建容器。可通过 `MONKEYAI_NO_PROXY` 指定直连地址,默认包含 `db,rustfs,localhost,127.0.0.1,::1`;覆盖时应保留这些地址及其他内网服务域名,避免内部流量误走代理。代理凭据如写入 `.env`,应限制该文件的读取权限。 + +| 出站请求 | 代理行为 | +|---|---| +| GitHub、Google、Microsoft、GitLab、OIDC 等登录提供方的元数据、令牌和用户信息 | 后端 HTTP(S) 请求使用环境代理;授权页面跳转由用户浏览器访问,不经过后端代理 | +| 文本模型上游、图片生成上游(OpenAI、xAI、火山引擎等)、Responses 用量补偿及 S3 对象存储 | 后端 HTTP(S) 请求使用环境代理;S3 等内网服务需配置直连 | +| 远程 MCP 连接器与其 OAuth 元数据、注册和令牌请求 | 支持环境代理;代理模式仅允许目标为通过地址策略检查的 IP 字面量,域名目标无法校验代理端 DNS 结果,会被拒绝;`NO_PROXY` 命中时仍按原有直连策略检查 | +| 百智云钱包 SDK | SDK 的专用 mTLS 传输未提供代理配置,仍然直连 | +| SMTP 邮件及 Admin 的 ACME 证书签发/续期 | 不使用后端 HTTP(S) 代理;分别通过 TCP 直连和 Admin 容器独立发起 | + ## 自动域名证书 Admin 镜像包含 Nginx 官方 ACME 模块,自动申请、续期并动态加载证书。功能默认关闭;Go 后端不参与证书管理。启用前,将域名解析到部署服务器,确保公网 TCP 80、443 可达,容器能够解析并访问 CA 的 HTTPS 接口。如果域名配置了 AAAA 记录,其 IPv6 入口也必须能完成验证。 diff --git a/monkeyai/backend/internal/mcp/transport.go b/monkeyai/backend/internal/mcp/transport.go index a98ab09f9..2445b6aaa 100644 --- a/monkeyai/backend/internal/mcp/transport.go +++ b/monkeyai/backend/internal/mcp/transport.go @@ -28,29 +28,75 @@ func allowedIP(ip netip.Addr) bool { } return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() } -func client() *http.Client { - return &http.Client{Timeout: 25 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error { return fmt.Errorf("不允许自动重定向") }, Transport: &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, err + +type proxyRoute struct{ url *url.URL } + +type proxyRouteKey struct{} + +type mcpTransport struct{ *http.Transport } + +func (t *mcpTransport) RoundTrip(r *http.Request) (*http.Response, error) { + proxy, err := http.ProxyFromEnvironment(r) + if err != nil { + return nil, err + } + route := proxyRoute{url: proxy} + if proxy != nil { + // 代理端解析域名时无法验证解析结果;只允许代理访问已获准的 IP 字面量。 + ip, err := netip.ParseAddr(r.URL.Hostname()) + if err != nil || ip.Zone() != "" { + return nil, fmt.Errorf("代理目标必须是无区域标识的 IP 地址,无法验证代理端 DNS") } - ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) - if err != nil { - return nil, err + if !allowedIP(ip) { + return nil, fmt.Errorf("目标地址不在允许范围内") } - for _, ip := range ips { - if !allowedIP(ip) { - return nil, fmt.Errorf("目标地址不在允许范围内") - } + if proxy.Scheme != "http" && proxy.Scheme != "https" { + return nil, fmt.Errorf("不支持的代理协议") } - for _, ip := range ips { - c, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) - if err == nil { - return c, nil + } + return t.Transport.RoundTrip(r.WithContext(context.WithValue(r.Context(), proxyRouteKey{}, route))) +} + +func client() *http.Client { + transport := &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + route, ok := r.Context().Value(proxyRouteKey{}).(proxyRoute) + if !ok { + return nil, fmt.Errorf("缺少代理路由") } - } - return nil, fmt.Errorf("无法连接目标") - }}} + return route.url, nil + }, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, err + } + route, ok := ctx.Value(proxyRouteKey{}).(proxyRoute) + if !ok { + return nil, fmt.Errorf("缺少代理路由") + } + // 使用代理时 DialContext 只连接代理;目标 IP 已在 RoundTrip 中校验。 + if route.url == nil { + for _, ip := range ips { + if !allowedIP(ip) { + return nil, fmt.Errorf("目标地址不在允许范围内") + } + } + } + for _, ip := range ips { + c, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if err == nil { + return c, nil + } + } + return nil, fmt.Errorf("无法连接目标") + }, + } + return &http.Client{Timeout: 25 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error { return fmt.Errorf("不允许自动重定向") }, Transport: &mcpTransport{transport}} } func validURL(value string) bool { u, err := url.Parse(value) diff --git a/monkeyai/backend/internal/mcp/transport_test.go b/monkeyai/backend/internal/mcp/transport_test.go index 6698b72db..05c5344c7 100644 --- a/monkeyai/backend/internal/mcp/transport_test.go +++ b/monkeyai/backend/internal/mcp/transport_test.go @@ -3,11 +3,15 @@ package mcp import ( "context" "encoding/json" - "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" "net/http" "net/http/httptest" "net/netip" + "os" + "os/exec" + "strings" "testing" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" ) func TestOutboundPolicy(t *testing.T) { @@ -57,3 +61,89 @@ func TestDiscoveryPagination(t *testing.T) { t.Fatalf("未完整发现分页目录: %+v %v", tools, err) } } + +func TestHTTPProxyPolicy(t *testing.T) { + if os.Getenv("MCP_PROXY_TEST_CHILD") == "" { + cmd := exec.Command(os.Args[0], "-test.run=^TestHTTPProxyPolicy$") + cmd.Env = append(os.Environ(), "MCP_PROXY_TEST_CHILD=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("代理策略子进程失败: %v\n%s", err, out) + } + return + } + + var requests []string + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.RequestURI) + if r.Method == http.MethodConnect { + w.WriteHeader(http.StatusBadGateway) + } else if r.URL.Path == "/redirect" { + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data", http.StatusFound) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer proxy.Close() + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("HTTPS_PROXY", proxy.URL) + t.Setenv("http_proxy", "") + t.Setenv("https_proxy", "") + t.Setenv("NO_PROXY", "127.0.0.1,localhost,198.18.0.1") + t.Setenv("no_proxy", "") + + direct := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer direct.Close() + request := func(target string) (*http.Response, error) { + c := client() + defer c.CloseIdleConnections() + return c.Get(target) + } + + if _, err := request(direct.URL); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { + t.Fatalf("直连私网未拦截: %v", err) + } + if len(requests) != 0 { + t.Fatalf("NO_PROXY 直连却访问了代理: %v", requests) + } + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8") + resp, err := request(direct.URL) + if err != nil || resp.StatusCode != http.StatusNoContent { + t.Fatalf("NO_PROXY 直连失败: %v, %v", resp, err) + } + resp.Body.Close() + if _, err = request("http://198.18.0.1/"); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { + t.Fatalf("NO_PROXY 目标未受直连限制: %v", err) + } + if len(requests) != 0 { + t.Fatalf("直连请求意外访问代理: %v", requests) + } + + resp, err = request("http://1.1.1.1/mcp") + if err != nil || resp.StatusCode != http.StatusOK || len(requests) != 1 || requests[0] != "GET http://1.1.1.1/mcp" { + t.Fatalf("HTTP 代理未使用本地代理: %v, %v, %v", resp, err, requests) + } + resp.Body.Close() + for _, target := range []string{"http://10.1.2.3/", "https://169.254.169.254/", "http://example.com/", "https://example.com/", "http://[2001:4860:4860::8888%25lo0]/"} { + if _, err = request(target); err == nil { + t.Fatalf("代理目标未拦截: %s", target) + } + } + if len(requests) != 1 { + t.Fatalf("被拒绝的请求访问了代理: %v", requests) + } + + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8,10.1.0.0/16") + resp, err = request("http://10.1.2.3/") + if err != nil || resp.StatusCode != http.StatusOK || len(requests) != 2 || requests[1] != "GET http://10.1.2.3/" { + t.Fatalf("代理目标白名单无效: %v, %v, %v", resp, err, requests) + } + resp.Body.Close() + if _, err = request("https://1.1.1.1/"); err == nil || len(requests) != 3 || requests[2] != "CONNECT 1.1.1.1:443" { + t.Fatalf("HTTPS CONNECT 未按目标 IP 发起: %v, %v", err, requests) + } + if _, err = request("http://1.1.1.1/redirect"); err == nil || !strings.Contains(err.Error(), "不允许自动重定向") || len(requests) != 4 { + t.Fatalf("代理重定向未拦截: %v, %v", err, requests) + } +} diff --git a/monkeyai/docker-compose.yml b/monkeyai/docker-compose.yml index d02fbe2e9..023bb0819 100644 --- a/monkeyai/docker-compose.yml +++ b/monkeyai/docker-compose.yml @@ -64,6 +64,9 @@ services: MONKEYAI_S3_SECRET_KEY: ${MONKEYAI_S3_SECRET_KEY:-${RUSTFS_SECRET_KEY}} MONKEYAI_S3_FORCE_PATH_STYLE: ${MONKEYAI_S3_FORCE_PATH_STYLE:-true} MONKEYAI_MCP_ALLOWED_CIDRS: ${MONKEYAI_MCP_ALLOWED_CIDRS:-} + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + NO_PROXY: ${MONKEYAI_NO_PROXY:-db,rustfs,localhost,127.0.0.1,::1} MONKEYAI_DATABASE_URL: ${MONKEYAI_DATABASE_URL:-postgres://${POSTGRES_USER:-monkeyai}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-monkeyai}?sslmode=disable} MONKEYAI_HTTP_ADDR: :8080