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
6 changes: 6 additions & 0 deletions monkeyai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
12 changes: 12 additions & 0 deletions monkeyai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 入口也必须能完成验证。
Expand Down
84 changes: 65 additions & 19 deletions monkeyai/backend/internal/mcp/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 91 additions & 1 deletion monkeyai/backend/internal/mcp/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions monkeyai/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading