From 8f7c492b70c735565524eff3c3f90177a3def4c3 Mon Sep 17 00:00:00 2001 From: JackFlynn Date: Tue, 4 Aug 2026 16:22:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E5=BC=80=E5=8F=91=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=20-=20=E6=96=B0=E5=A2=9E=20Docker=20=E7=83=AD?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=B9=B6=E5=AE=8C=E5=96=84=E7=A7=81=E7=BD=91?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E6=8C=87=E5=BC=95=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(platform): 开发环境 - 新增 Docker 热更新开发配置 * docs(platform): 开发环境 - 记录 Fork 分支同步流程 * fix(platform): 开发环境 - 避让端口冲突并持久化构建缓存 * fix(platform): 模型渠道 - 改进私网上游配置指引 * docs(platform): 开发环境 - 本机升级流程仅保留在本地手册 * feat(security): 自定义渠道 - 允许白名单主机使用 HTTP 中转 --------- Co-authored-by: sqy941013 --- .dockerignore | 1 + .env.example | 2 + README.md | 31 ++++++++- backend/.air.toml | 19 ++++++ backend/Dockerfile.dev | 14 +++++ backend/internal/service/outbound.go | 12 ++-- backend/internal/service/outbound_test.go | 13 ++-- docker-compose.dev.yml | 76 +++++++++++++++++++++++ web/src/pages/settings/index.tsx | 11 +++- web/vite.config.ts | 7 ++- 10 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 backend/.air.toml create mode 100644 backend/Dockerfile.dev create mode 100644 docker-compose.dev.yml diff --git a/.dockerignore b/.dockerignore index 968816d8c..c46374570 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,7 @@ data backend/data backend/*.db .serena +.local web/.next web/node_modules web/out diff --git a/.env.example b/.env.example index 138e7391c..4a914017a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # 本文件只声明可公开的运行配置,不要在这里填写任何密钥。 CANVAS_REGISTRATION_ENABLED=false +# 默认拒绝后端访问本机和私网;可信私网或 HTTP 服务按主机精确放行,不要填写协议、端口或路径。 CANVAS_ALLOW_PRIVATE_UPSTREAMS=false +# 多个主机使用英文逗号分隔,例如:gateway.internal,192.168.1.10 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS= CANVAS_CORS_ORIGINS=http://localhost:3000 CANVAS_DATA_PATH=./data diff --git a/README.md b/README.md index 3cdf56d9d..81f70262c 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,24 @@ curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/script ```bash git clone https://github.com/ddcat-ai/open-ai-canvas.git cd open-ai-canvas +``` +后端开发数据统一保存在 Git 忽略的 `.local/project-workbench-debug`。启动前先检查该目录,不要改用 `backend/data` 或其他目录;仅在确认本机没有既有开发数据时创建: + +```bash +if [ -d .local/project-workbench-debug ]; then + find .local/project-workbench-debug -maxdepth 1 -type f -name 'open_ai_canvas.db*' -ls +else + mkdir -p .local/project-workbench-debug +fi +mkdir -p .local/cache/go-build .local/cache/go-mod +``` + +直接在宿主机开发: + +```bash cd backend -go run ./cmd/server +CANVAS_BACKEND_DATA_DIR=../.local/project-workbench-debug go run ./cmd/server # 另开终端 cd web @@ -132,7 +147,19 @@ bun run dev 资源配额、Worker/渠道/账号任务并发、业务频控、任务超时和渠道中转策略可在“系统配置 → 资源与策略”中统一热更新,支持重置和自用模式;系统渠道可选择跟随全局值,或单独设置 `1-999` 的最大并发数。未保存后台配置时 Worker 和全局渠道并发分别回退到 `CANVAS_WORKER_CONCURRENCY` 和 `CANVAS_CHANNEL_CONCURRENCY`,两者默认均为 `3`。渠道槽位暂满时任务会等待,不会直接标记失败。 -Docker 一体化运行: +Docker 热更新开发: + +```bash +LOCAL_UID=$(id -u) LOCAL_GID=$(id -g) docker compose -f docker-compose.dev.yml up --build +``` + +前端通过 Vite HMR 更新,后端由 Air 在 Go 源码或模块文件变化后重新编译。前端地址为 `http://localhost:3000`,局域网设备可通过 `http://<开发机 IP>:3000` 访问;后端 `8080` 端口只开放给开发机本机,浏览器 API 请求统一由 Vite 转发。 + +宿主机端口冲突时可通过 `CANVAS_WEB_HOST_PORT` 和 `CANVAS_BACKEND_HOST_PORT` 覆盖默认的 3000/8080;建议把本机取值写入 Git 忽略的 `.local/docker-compose.dev.env`,并在 Compose 命令中使用 `--env-file .local/docker-compose.dev.env`。Go 模块和编译缓存保存在 `.local/cache`,重建容器不会重复完整冷编译。 + +后端默认通过 SSRF 防护拒绝本机、私网和链路本地上游。开发环境需要连接可信局域网模型服务时,在 `.local/docker-compose.dev.env` 中使用 `CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS=192.168.1.10` 精确放行;只填写主机名或 IP,多个值用英文逗号分隔,不包含协议、端口和路径。精确白名单中的自定义渠道可使用 HTTP,但 API Key 会在后端到上游的链路中明文传输,仅限可信网络;其他自定义渠道仍要求 HTTPS。保持 `CANVAS_ALLOW_PRIVATE_UPSTREAMS=false`,避免放行所有私网目标。 + +Docker 一体化运行(静态前端和 release 后端,不提供源码热更新): ```bash docker compose -f docker-compose.local.yml up -d --build diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 000000000..bf95a6a74 --- /dev/null +++ b/backend/.air.toml @@ -0,0 +1,19 @@ +root = "." +tmp_dir = "/tmp/open-ai-canvas-air" + +[build] +bin = "/tmp/open-ai-canvas-air/server" +cmd = "go build -o /tmp/open-ai-canvas-air/server ./cmd/server" +delay = 800 +exclude_dir = ["data", "tmp", "vendor"] +exclude_regex = ["_test\\.go"] +include_ext = ["go", "mod", "sum"] +kill_delay = "500ms" +send_interrupt = true +stop_on_error = true + +[log] +time = true + +[misc] +clean_on_exit = true diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 000000000..5503628d7 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,14 @@ +ARG GO_IMAGE=golang:1.25-alpine +FROM ${GO_IMAGE} + +ARG GOPROXY=https://proxy.golang.org,direct +ARG ALPINE_MIRROR +ENV GOPROXY=$GOPROXY + +RUN if [ -n "$ALPINE_MIRROR" ]; then sed -i "s#https://dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#g" /etc/apk/repositories; fi \ + && apk add --no-cache build-base git \ + && go install github.com/air-verse/air@v1.63.0 + +WORKDIR /workspace/backend + +CMD ["air", "-c", ".air.toml"] diff --git a/backend/internal/service/outbound.go b/backend/internal/service/outbound.go index bbb8afe1d..ae31be133 100644 --- a/backend/internal/service/outbound.go +++ b/backend/internal/service/outbound.go @@ -62,8 +62,8 @@ func ValidateOutboundURL(rawURL string) (*url.URL, error) { return parsed, nil } -// 用户自定义渠道必须使用更严格的出口策略:只允许 HTTPS,不接受 URL 凭据, -// 仅部署者精确配置的主机可以路由到私网。 +// 用户自定义渠道必须使用更严格的出口策略:不接受 URL 凭据,且仅部署者精确配置的 +// 主机可以路由到私网或使用会明文传输 API Key 的 HTTP。 func ValidateCustomRelayURL(rawURL string) (*url.URL, error) { if len(strings.TrimSpace(rawURL)) > 4096 { return nil, BadAuthRequest("自定义渠道地址过长") @@ -72,8 +72,12 @@ func ValidateCustomRelayURL(rawURL string) (*url.URL, error) { if err != nil || parsed.Hostname() == "" || !parsed.IsAbs() { return nil, BadAuthRequest("自定义渠道地址无效") } - if parsed.Scheme != "https" { - return nil, BadAuthRequest("自定义渠道中转只支持 HTTPS") + scheme := strings.ToLower(parsed.Scheme) + if scheme != "https" && scheme != "http" { + return nil, BadAuthRequest("自定义渠道地址只支持 http/https") + } + if scheme == "http" && !allowedPrivateUpstreamHost(parsed.Hostname()) { + return nil, BadAuthRequest("自定义渠道 HTTP 仅允许访问已配置的可信上游主机") } if parsed.User != nil { return nil, BadAuthRequest("自定义渠道地址不允许包含认证信息") diff --git a/backend/internal/service/outbound_test.go b/backend/internal/service/outbound_test.go index a2db34ed2..1ab46fc19 100644 --- a/backend/internal/service/outbound_test.go +++ b/backend/internal/service/outbound_test.go @@ -105,18 +105,23 @@ func TestAllowedPrivateUpstreamHostUsesExactCaseInsensitiveMatch(t *testing.T) { } } -func TestValidateCustomRelayURLUsesHTTPSAndExactPrivateAllowlist(t *testing.T) { +func TestValidateCustomRelayURLAllowsHTTPOnlyForExactPrivateAllowlist(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "") if _, err := ValidateCustomRelayURL("http://127.0.0.1:8080/v1/models"); err == nil { - t.Fatal("ValidateCustomRelayURL() should reject HTTP") + t.Fatal("ValidateCustomRelayURL() should reject HTTP without an exact host allowlist") } if _, err := ValidateCustomRelayURL("https://127.0.0.1:8080/v1/models"); err == nil { t.Fatal("ValidateCustomRelayURL() should ignore the global private upstream override") } t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") - if _, err := ValidateCustomRelayURL("https://127.0.0.1:8080/v1/models"); err != nil { - t.Fatalf("ValidateCustomRelayURL() error = %v", err) + for _, rawURL := range []string{ + "http://127.0.0.1:8080/v1/models", + "https://127.0.0.1:8080/v1/models", + } { + if _, err := ValidateCustomRelayURL(rawURL); err != nil { + t.Fatalf("ValidateCustomRelayURL(%q) error = %v", rawURL, err) + } } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000..eddb70684 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,76 @@ +name: open-ai-canvas-dev + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile.dev + args: + GO_IMAGE: ${GO_IMAGE:-golang:1.25-alpine} + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + ALPINE_MIRROR: ${ALPINE_MIRROR:-} + working_dir: /workspace/backend + user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" + environment: + HOME: /tmp + GOCACHE: /tmp/go-build + GOMODCACHE: /tmp/go-mod + CANVAS_BACKEND_ADDR: ":8080" + CANVAS_BACKEND_DATA_DIR: /data + CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-sqlite} + DATABASE_URL: ${DATABASE_URL:-} + REDIS_URL: ${REDIS_URL:-} + CANVAS_WORKER_CONCURRENCY: ${CANVAS_WORKER_CONCURRENCY:-3} + CANVAS_CHANNEL_CONCURRENCY: ${CANVAS_CHANNEL_CONCURRENCY:-3} + CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false} + CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false} + CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-} + CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000} + GIN_MODE: debug + volumes: + - type: bind + source: . + target: /workspace + # 禁止 Compose 静默创建新数据目录,避免开发时误用另一套账号数据。 + - type: bind + source: ./.local/project-workbench-debug + target: /data + bind: + create_host_path: false + # 持久化首次下载和 CGO 编译结果,容器重建后继续复用开发缓存。 + - type: bind + source: ./.local/cache/go-build + target: /tmp/go-build + bind: + create_host_path: false + - type: bind + source: ./.local/cache/go-mod + target: /tmp/go-mod + bind: + create_host_path: false + ports: + - "127.0.0.1:${CANVAS_BACKEND_HOST_PORT:-8080}:8080" + init: true + + web: + image: oven/bun:1.3.13 + working_dir: /workspace/web + command: ["sh", "-c", "bun install --frozen-lockfile && bun run dev"] + environment: + VITE_API_PROXY_TARGET: http://backend:8080 + VITE_TLDRAW_LICENSE_KEY: ${VITE_TLDRAW_LICENSE_KEY:-} + volumes: + - type: bind + source: . + target: /workspace + - web-node-modules:/workspace/web/node_modules + - web-bun-cache:/root/.bun/install/cache + ports: + - "${CANVAS_WEB_HOST_PORT:-3000}:3000" + depends_on: + - backend + init: true + +volumes: + web-node-modules: + web-bun-cache: diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 13a2d78b2..c3abcd2dd 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -50,6 +50,15 @@ function isConfigSection(value: string | null): value is ConfigSectionKey { return configSections.some((section) => section.key === value); } +function channelModelFetchErrorMessage(error: unknown) { + const detail = error instanceof Error ? error.message : "读取模型失败"; + // 私网地址会在实际生成时继续被 SSRF 防护拦截,不能提示用户靠手填模型绕过。 + if (detail.includes("不允许访问本机") || detail.includes("不允许访问保留地址")) { + return `${detail};可信私网服务需由部署管理员配置 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS`; + } + return `${detail};也可以直接在模型列表中手动输入模型名`; +} + export default function SettingsPage() { const { message } = App.useApp(); const navigate = useNavigate(); @@ -184,7 +193,7 @@ export default function SettingsPage() { ); message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`); } catch (error) { - message.error(error instanceof Error ? `${error.message};也可以直接在模型列表中手动输入模型名` : "读取模型失败,可直接手动输入模型名"); + message.error(channelModelFetchErrorMessage(error)); } finally { setChannelLoading(channel.id, false); } diff --git a/web/vite.config.ts b/web/vite.config.ts index 71d3da651..e913b3dff 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,6 +7,7 @@ import { defineConfig } from "vite"; const webDir = dirname(fileURLToPath(import.meta.url)); const appVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim(); const appChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); +const apiProxyTarget = process.env.VITE_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8080"; export default defineConfig({ plugins: [react()], @@ -17,12 +18,14 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://127.0.0.1:8080", + target: apiProxyTarget, changeOrigin: true, + xfwd: true, }, "/oauth/linuxdo/callback": { - target: "http://127.0.0.1:8080", + target: apiProxyTarget, changeOrigin: true, + xfwd: true, }, }, },