Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion monkeyai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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。
# 后端 OAuth、S3 等请求使用的代理;模型、图片及 MCP 工具调用始终直连,MCP OAuth 令牌交换可代理。
# HTTP_PROXY=http://proxy.example.com:7890
# HTTPS_PROXY=http://proxy.example.com:7890
# 内部服务必须直连;自定义时保留 db、rustfs 和本机地址。
Expand Down
8 changes: 5 additions & 3 deletions monkeyai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ docker compose up --build

## 后端出站代理

在 `.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`,应限制该文件的读取权限。
在 `.env` 中设置 `HTTP_PROXY`、`HTTPS_PROXY`(例如 `http://proxy.example.com:7890`),Compose 会将其传入 backend,供 OAuth 和对象存储等非模型代理请求使用;不配置时保持直连。修改后运行 `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` 命中时仍按原有直连策略检查 |
| S3 对象存储 | 使用环境代理;内网存储服务需配置直连 |
| llmproxy 文本模型上游及 Responses 用量补偿、imageproxy 图片生成上游(OpenAI、xAI、火山引擎等) | 始终直连,不读取环境代理配置 |
| 远程 MCP 工具调用、OAuth 元数据与动态注册 | 始终直连,继续执行目标地址限制和禁止重定向策略 |
| MCP OAuth 首次交换及刷新令牌 | 允许经 `HTTPS_PROXY` 访问 HTTPS 令牌端点(代理地址须为 HTTP 代理);无代理或 `NO_PROXY` 命中时直连。代理请求会在本地检查目标解析结果,CONNECT 固定到获准的 IP,仍校验原域名的 TLS 证书 |
| 百智云钱包 SDK | SDK 的专用 mTLS 传输未提供代理配置,仍然直连 |
| SMTP 邮件及 Admin 的 ACME 证书签发/续期 | 不使用后端 HTTP(S) 代理;分别通过 TCP 直连和 Admin 容器独立发起 |

Expand Down
2 changes: 1 addition & 1 deletion monkeyai/backend/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo
imageInputs := imagegen.NewInputs(imageRepo, storage)
imageOutputs := imagegen.NewOutputs(imageRepo, storage)
imageService := imagegen.NewService(modelRepo, imageRepo, imageInputs, imageOutputs, charges)
upstreamClient := &http.Client{Timeout: 10 * time.Minute}
upstreamClient := &http.Client{Timeout: 10 * time.Minute, Transport: proxy.DirectTransport()}
gptImages := openaiimages.New(upstreamClient)
gptResponses := openairesponses.New(upstreamClient)
seedream := volcengine.New(upstreamClient)
Expand Down
11 changes: 10 additions & 1 deletion monkeyai/backend/internal/mcp/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ func safeMCPFailure(err error) []any {
}

func exchange(ctx context.Context, c resource.Object, v url.Values) (tokens, error) {
return exchangeWithProxy(ctx, c, v, http.ProxyFromEnvironment)
}

func exchangeWithProxy(ctx context.Context, c resource.Object, v url.Values, proxy func(*http.Request) (*url.URL, error)) (tokens, error) {
o := oauthSettings(c)
if o.clientSecretExpired() {
return tokens{}, invalidGrant
Expand All @@ -419,6 +423,8 @@ func exchange(ctx context.Context, c resource.Object, v url.Values) (tokens, err
if secret != "" && (o.TokenAuthMethod == "" || o.TokenAuthMethod == "client_secret_post") {
v.Set("client_secret", secret)
}
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", o.TokenURL, strings.NewReader(v.Encode()))
if err != nil {
return tokens{}, tokenExchangeError{reason: "invalid_token_url"}
Expand All @@ -428,7 +434,10 @@ func exchange(ctx context.Context, c resource.Object, v url.Values) (tokens, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
h := client()
h, req, err := tokenClient(req, proxy, net.DefaultResolver.LookupNetIP)
if err != nil {
return tokens{}, err
}
defer h.CloseIdleConnections()
resp, err := h.Do(req)
if err != nil {
Expand Down
37 changes: 37 additions & 0 deletions monkeyai/backend/internal/mcp/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,43 @@ func TestExchangeHTTPFailureExcludesResponse(t *testing.T) {
}
}

func TestExchangeNoProxyKeepsDirectPolicy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/redirect" {
http.Redirect(w, r, "/token", http.StatusFound)
return
}
resource.JSON(w, http.StatusOK, resource.Object{"access_token": "direct"})
}))
defer server.Close()
noProxy := func(*http.Request) (*url.URL, error) { return nil, nil }
config := func(target string) resource.Object {
return resource.Object{"oauth_config": oauthConfig{ClientID: "client", TokenURL: target}}
}
t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "")
if _, err := exchangeWithProxy(t.Context(), config(server.URL), url.Values{"grant_type": {"refresh_token"}}, noProxy); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") {
t.Fatalf("未继承直连目标 IP 校验: %v", err)
}
t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8")
result, err := exchangeWithProxy(t.Context(), config(server.URL), url.Values{"grant_type": {"authorization_code"}}, noProxy)
if err != nil || result.Access != "direct" {
t.Fatalf("无代理时 OAuth 授权交换未直连: %+v %v", result, err)
}
if _, err := exchangeWithProxy(t.Context(), config(server.URL+"/redirect"), url.Values{"grant_type": {"refresh_token"}}, noProxy); err == nil || !strings.Contains(err.Error(), "不允许自动重定向") {
t.Fatalf("未继承直连禁重定向策略: %v", err)
}
}

func TestExchangeRejectsHTTPProxyTarget(t *testing.T) {
proxyURL, _ := url.Parse("http://127.0.0.1:3128")
_, err := exchangeWithProxy(t.Context(), resource.Object{"oauth_config": oauthConfig{TokenURL: "http://example.com/token"}}, url.Values{"grant_type": {"refresh_token"}}, func(*http.Request) (*url.URL, error) {
return proxyURL, nil
})
if err == nil || !strings.Contains(err.Error(), "仅支持 HTTPS 目标") {
t.Fatalf("HTTP Token URL 不得由代理解析: %v", err)
}
}

func TestOAuthFailureDoesNotExposeURL(t *testing.T) {
err := &url.Error{Op: "POST", URL: "https://oauth.example/token?code=private-code", Err: errors.New("private-token")}
logged := fmt.Sprint(safeMCPFailure(err))
Expand Down
120 changes: 57 additions & 63 deletions monkeyai/backend/internal/mcp/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
Expand Down Expand Up @@ -29,75 +30,68 @@ func allowedIP(ip netip.Addr) bool {
return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}

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")
}
if !allowedIP(ip) {
return nil, fmt.Errorf("目标地址不在允许范围内")
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
}
if proxy.Scheme != "http" && proxy.Scheme != "https" {
return nil, fmt.Errorf("不支持的代理协议")
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
}
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("缺少代理路由")
for _, ip := range ips {
if !allowedIP(ip) {
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
}
}
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 nil, fmt.Errorf("无法连接目标")
}}}
}
func tokenClient(req *http.Request, proxy func(*http.Request) (*url.URL, error), lookup func(context.Context, string, string) ([]netip.Addr, error)) (*http.Client, *http.Request, error) {
h := client()
p, err := proxy(req)
if err != nil || p == nil {
return h, req, err
}
if req.URL.Scheme != "https" || p.Scheme != "http" || p.Host == "" {
return nil, nil, fmt.Errorf("OAuth Token 代理仅支持 HTTPS 目标和 HTTP 代理")
}
return &http.Client{Timeout: 25 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error { return fmt.Errorf("不允许自动重定向") }, Transport: &mcpTransport{transport}}
name := req.URL.Hostname()
ips, err := lookup(req.Context(), "ip", name)
if err != nil {
return nil, nil, err
}
if len(ips) == 0 {
return nil, nil, fmt.Errorf("无法解析 OAuth Token 目标")
}
for _, ip := range ips {
if ip.Zone() != "" || !allowedIP(ip) {
return nil, nil, fmt.Errorf("目标地址不在允许范围内")
}
}
target := req.Clone(req.Context())
u := *req.URL
port := u.Port()
if port == "" {
port = "443"
}
u.Host = net.JoinHostPort(ips[0].Unmap().String(), port)
target.URL = &u
target.Host = req.URL.Host
h.Transport = &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) { return p, nil },
TLSClientConfig: &tls.Config{ServerName: name},
}
return h, target, nil
}

func validURL(value string) bool {
u, err := url.Parse(value)
return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil && u.Fragment == ""
Expand Down
Loading
Loading