feat(platform): 开发环境 - 新增 Docker 热更新并完善私网上游指引 (#122)

* feat(platform): 开发环境 - 新增 Docker 热更新开发配置

* docs(platform): 开发环境 - 记录 Fork 分支同步流程

* fix(platform): 开发环境 - 避让端口冲突并持久化构建缓存

* fix(platform): 模型渠道 - 改进私网上游配置指引

* docs(platform): 开发环境 - 本机升级流程仅保留在本地手册

* feat(security): 自定义渠道 - 允许白名单主机使用 HTTP 中转

---------

Co-authored-by: sqy941013 <sqy941013@users.noreply.github.com>
This commit is contained in:
JackFlynn
2026-08-04 16:22:27 +08:00
committed by GitHub
parent d4018123e1
commit 8f7c492b70
10 changed files with 173 additions and 13 deletions

View File

@@ -10,6 +10,7 @@ data
backend/data
backend/*.db
.serena
.local
web/.next
web/node_modules
web/out

View File

@@ -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

View File

@@ -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

19
backend/.air.toml Normal file
View File

@@ -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

14
backend/Dockerfile.dev Normal file
View File

@@ -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"]

View File

@@ -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("自定义渠道地址不允许包含认证信息")

View File

@@ -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)
}
}
}

76
docker-compose.dev.yml Normal file
View File

@@ -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:

View File

@@ -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);
}

View File

@@ -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,
},
},
},