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:
@@ -10,6 +10,7 @@ data
|
||||
backend/data
|
||||
backend/*.db
|
||||
.serena
|
||||
.local
|
||||
web/.next
|
||||
web/node_modules
|
||||
web/out
|
||||
|
||||
@@ -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
|
||||
|
||||
31
README.md
31
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
|
||||
|
||||
19
backend/.air.toml
Normal file
19
backend/.air.toml
Normal 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
14
backend/Dockerfile.dev
Normal 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"]
|
||||
@@ -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("自定义渠道地址不允许包含认证信息")
|
||||
|
||||
@@ -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
76
docker-compose.dev.yml
Normal 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:
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user