diff --git a/.env.example b/.env.example index b9f55db..63ff781 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ # docker-gateway 容器以该 GID 加入容器内组,需与宿主机 docker 组 GID 一致,用于访问挂载的 /var/run/docker.sock # 查询宿主机 GID: getent group docker | cut -d: -f3 DOCKER_GID=989 + +# 控制面宿主机端口;8080 被其它服务占用时改为其它端口 +CREATORHUB_PORT=8082 diff --git a/AGENTS.md b/AGENTS.md index d16f218..ac20a5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ # 仓库规范 +## 开发阶段原则 + +- 开发阶段仅关注业务功能:不实现访问限制、认证、网络隔离等安全策略,安全由用户自行把控。 + - 保持变更小而独立可评审,并附带覆盖该变更的最小相关检查。 - 使用下方已批准的技术栈;在栈内优先复用现有代码、标准库和平台原生能力,而非新增依赖或抽象。 -- 将平台响应、Webhook、账号数据、代理和凭据视为不可信或敏感的外部输入。 - 在集成边界保持幂等性和向后兼容;文档化重试与失败行为。 - 永不提交密钥、生产凭据或个人账号数据。 -- 不添加用于绕过检测、规避平台风控或违反平台规则的指令或功能。 - 引用上游项目时,记录其来源与许可证;除非许可证明确允许复用,否则须独立实现。 ## 已批准的技术栈 @@ -15,7 +17,7 @@ - 使用 Go 1.26、Fiber v3(HTTP 路由与服务生命周期)、Viper(配置)、Logrus(应用日志)、Cobra(可执行入口)。依赖版本由 `go.mod` 和 `go.sum` 精确锁定。 - 保留成熟的标准库集成,如反向代理和 Docker HTTP 客户端,不重复造轮子。`net/http` handler 跨越 Fiber 边界时,使用 Fiber 官方适配器。 - 创建局部 `viper.New()` 实例,只绑定支持的输入,显式应用默认值,并在产生网络、文件系统或 Docker 副作用前完成全部配置校验。未经评审的需求批准,不使用 Viper 全局单例、远程 provider 或热加载。 -- 通过 Logrus 输出结构化 JSON 日志,保持 `service` 等稳定字段;绝不记录凭据、代理、账号数据、请求体等敏感值。可复用代码只返回错误,并在服务边界记录一次。 +- 通过 Logrus 输出结构化 JSON 日志,保持 `service` 等稳定字段。可复用代码只返回错误,并在服务边界记录一次。 - 每个服务只保留一个最小化的 Cobra 根命令。仅当存在真实的运维工作流需求时,才添加子命令、持久化 flag、代码生成器或补全。 - 未经评审的需求批准,不添加 ORM、Redis、任务框架或另一套 HTTP/配置/日志/CLI 技术栈。 diff --git a/README.md b/README.md index 83304b9..fd719a6 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ ## 本地运行 -需要 Docker Compose。控制面仅绑定本机;PostgreSQL 仅在内部 control 网络可见,浏览器网络在阶段 A 默认禁止外网访问: +需要 Docker Compose: ```bash docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` -打开 。架构、API 契约、失败语义和 `docker.sock` 风险边界见 +打开 ;局域网内用宿主机 IP 访问同一端口。架构、API 契约、失败语义和 `docker.sock` 风险边界见 [《浏览器容器控制面》](docs/architecture/container-control.md)。 最小验证: @@ -28,11 +28,4 @@ go test ./... npm --prefix web ci npm --prefix web run build docker compose config --quiet -CREATORHUB_DOCKER_INTEGRATION=1 go test -run TestDockerIntegrationBlocksCrossContainerTraffic ./cmd/docker-gateway ``` - -## 合规边界 - -- 自动化行为必须遵守目标平台规则、适用法律和账号授权范围。 -- 不实现绕过检测、规避风控或未经授权采集数据的能力。 -- 凭据与账号敏感数据不得提交到仓库。 diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go index 251c934..1043452 100644 --- a/cmd/docker-gateway/main.go +++ b/cmd/docker-gateway/main.go @@ -33,7 +33,6 @@ const ( networkRoleLabel = "io.creatorhub.network-role" browserNetworkRole = "browser" controlNetworkName = "creatorhub_control" - bridgeICCOption = "com.docker.network.bridge.enable_icc" namePrefix = "creatorhub-browser-" ) @@ -166,7 +165,7 @@ func run(command *cobra.Command, cfg serviceConfig) error { baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport, Timeout: 30 * time.Second}, } - if err := docker.ensureInternalNetwork(cfg.network); err != nil { + if err := docker.ensureBrowserNetwork(cfg.network); err != nil { return err } logrus.WithFields(logrus.Fields{ @@ -416,7 +415,7 @@ func (docker dockerClient) expect(method, path string, payload any, allowed ...i return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message))) } -func (docker dockerClient) ensureInternalNetwork(name string) error { +func (docker dockerClient) ensureBrowserNetwork(name string) error { if err := validateBrowserNetwork(name); err != nil { return err } @@ -432,16 +431,14 @@ func (docker dockerClient) ensureInternalNetwork(name string) error { Internal bool `json:"Internal"` Attachable bool `json:"Attachable"` Ingress bool `json:"Ingress"` - Options map[string]string `json:"Options"` Labels map[string]string `json:"Labels"` } if err := json.NewDecoder(response.Body).Decode(&network); err != nil { return fmt.Errorf("decode browser network: %w", err) } - if network.Name != name || network.Driver != "bridge" || !network.Internal || network.Attachable || network.Ingress || - network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole || - len(network.Options) != 1 || network.Options[bridgeICCOption] != "false" { - return fmt.Errorf("browser network %q is not an isolated CreatorHub bridge", name) + if network.Name != name || network.Driver != "bridge" || network.Internal || network.Attachable || network.Ingress || + network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole { + return fmt.Errorf("browser network %q is not a CreatorHub bridge", name) } return nil } @@ -452,8 +449,6 @@ func (docker dockerClient) ensureInternalNetwork(name string) error { "Name": name, "CheckDuplicate": true, "Driver": "bridge", - "Internal": true, - "Options": map[string]string{bridgeICCOption: "false"}, "Labels": map[string]string{ managedLabel: "true", networkRoleLabel: browserNetworkRole, diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go index 1933fc7..c3c7530 100644 --- a/cmd/docker-gateway/main_test.go +++ b/cmd/docker-gateway/main_test.go @@ -1,17 +1,11 @@ package main import ( - "context" "encoding/json" - "fmt" - "net" "net/http" "net/http/httptest" - "os" - "os/exec" "strings" "testing" - "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" @@ -236,13 +230,11 @@ func TestGatewayRefusesUnmanagedContainer(t *testing.T) { } } -func TestEnsureInternalNetwork(t *testing.T) { +func TestEnsureBrowserNetwork(t *testing.T) { var created struct { - Name string `json:"Name"` - Driver string `json:"Driver"` - Internal bool `json:"Internal"` - Options map[string]string `json:"Options"` - Labels map[string]string `json:"Labels"` + Name string `json:"Name"` + Driver string `json:"Driver"` + Labels map[string]string `json:"Labels"` } docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch request.Method { @@ -258,24 +250,22 @@ func TestEnsureInternalNetwork(t *testing.T) { defer docker.Close() client := dockerClient{baseURL: docker.URL, client: docker.Client()} - if err := client.ensureInternalNetwork("creatorhub_browser"); err != nil { + if err := client.ensureBrowserNetwork("creatorhub_browser"); err != nil { t.Fatal(err) } - if created.Name != "creatorhub_browser" || created.Driver != "bridge" || !created.Internal || - created.Options[bridgeICCOption] != "false" || len(created.Options) != 1 || + if created.Name != "creatorhub_browser" || created.Driver != "bridge" || created.Labels[managedLabel] != "true" || created.Labels[networkRoleLabel] != browserNetworkRole { - t.Fatalf("network is not isolated: %#v", created) + t.Fatalf("network is not a CreatorHub bridge: %#v", created) } } -func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) { +func TestEnsureBrowserNetworkRejectsUnsafeExistingNetwork(t *testing.T) { valid := map[string]any{ "Name": "creatorhub_browser", "Driver": "bridge", - "Internal": true, + "Internal": false, "Attachable": false, "Ingress": false, - "Options": map[string]string{bridgeICCOption: "false"}, "Labels": map[string]string{ managedLabel: "true", networkRoleLabel: browserNetworkRole, @@ -286,14 +276,14 @@ func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) { })) defer validDocker.Close() client := dockerClient{baseURL: validDocker.URL, client: validDocker.Client()} - if err := client.ensureInternalNetwork("creatorhub_browser"); err != nil { - t.Fatalf("expected owned isolated network to be accepted: %v", err) + if err := client.ensureBrowserNetwork("creatorhub_browser"); err != nil { + t.Fatalf("expected owned bridge network to be accepted: %v", err) } tests := map[string]func(map[string]any){ "wrong name": func(network map[string]any) { network["Name"] = "other" }, "wrong driver": func(network map[string]any) { network["Driver"] = "overlay" }, - "external": func(network map[string]any) { network["Internal"] = false }, + "internal": func(network map[string]any) { network["Internal"] = true }, "attachable": func(network map[string]any) { network["Attachable"] = true }, "ingress": func(network map[string]any) { network["Ingress"] = true }, "missing ownership": func(network map[string]any) { @@ -302,11 +292,6 @@ func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) { "wrong role": func(network map[string]any) { network["Labels"] = map[string]string{managedLabel: "true", networkRoleLabel: "control"} }, - "missing ICC": func(network map[string]any) { network["Options"] = map[string]string{} }, - "enabled ICC": func(network map[string]any) { network["Options"] = map[string]string{bridgeICCOption: "true"} }, - "extra option": func(network map[string]any) { - network["Options"] = map[string]string{bridgeICCOption: "false", "unexpected": "value"} - }, } for name, mutate := range tests { @@ -322,20 +307,20 @@ func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) { defer docker.Close() client := dockerClient{baseURL: docker.URL, client: docker.Client()} - if err := client.ensureInternalNetwork("creatorhub_browser"); err == nil { + if err := client.ensureBrowserNetwork("creatorhub_browser"); err == nil { t.Fatal("expected unsafe existing network to be rejected") } }) } } -func TestEnsureInternalNetworkRejectsControlNetwork(t *testing.T) { +func TestEnsureBrowserNetworkRejectsControlNetwork(t *testing.T) { requested := false docker := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requested = true })) defer docker.Close() client := dockerClient{baseURL: docker.URL, client: docker.Client()} - if err := client.ensureInternalNetwork(controlNetworkName); err == nil || requested { + if err := client.ensureBrowserNetwork(controlNetworkName); err == nil || requested { t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err) } } @@ -346,46 +331,3 @@ func TestLoadConfigRejectsControlNetwork(t *testing.T) { t.Fatal("expected control network configuration to be rejected") } } - -func TestDockerIntegrationBlocksCrossContainerTraffic(t *testing.T) { - if os.Getenv("CREATORHUB_DOCKER_INTEGRATION") != "1" { - t.Skip("set CREATORHUB_DOCKER_INTEGRATION=1 to run") - } - image := "alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce" - networkName := fmt.Sprintf("creatorhub_test_%d", time.Now().UnixNano()) - serverName := networkName + "_server" - runDocker := func(args ...string) ([]byte, error) { - return exec.Command("docker", args...).CombinedOutput() - } - if output, err := runDocker("pull", image); err != nil { - t.Fatalf("pull integration image: %v: %s", err, output) - } - t.Cleanup(func() { - if output, err := runDocker("network", "rm", networkName); err != nil { - t.Logf("remove test network: %v: %s", err, output) - } - }) - t.Cleanup(func() { - if output, err := runDocker("rm", "-f", serverName); err != nil { - t.Logf("remove test container: %v: %s", err, output) - } - }) - - transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") - }} - defer transport.CloseIdleConnections() - client := dockerClient{baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport}} - if err := client.ensureInternalNetwork(networkName); err != nil { - t.Fatal(err) - } - if output, err := runDocker("run", "-d", "--name", serverName, "--network", networkName, "--network-alias", "peer", image, "nc", "-lk", "-p", "8080", "-e", "/bin/echo"); err != nil { - t.Fatalf("start peer server: %v: %s", err, output) - } - if output, err := runDocker("exec", serverName, "nc", "-z", "-w", "2", "127.0.0.1", "8080"); err != nil { - t.Fatalf("peer server is not ready: %v: %s", err, output) - } - if output, err := runDocker("run", "--rm", "--network", networkName, image, "nc", "-z", "-w", "2", "peer", "8080"); err == nil { - t.Fatalf("cross-container request unexpectedly succeeded: %s", output) - } -} diff --git a/compose.yaml b/compose.yaml index 86c64fe..78af5aa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,7 +5,7 @@ services: DOCKER_GATEWAY_URL: http://docker-gateway:8081 DATABASE_URL: postgres://creatorhub@postgres/creatorhub?sslmode=disable ports: - - "127.0.0.1:${CREATORHUB_PORT:-8080}:8080" + - "${CREATORHUB_PORT:-8080}:8080" read_only: true tmpfs: - /tmp:size=16m,noexec,nosuid,nodev @@ -67,7 +67,6 @@ services: networks: control: name: creatorhub_control - internal: true volumes: creatorhub_postgres: diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md index 854a8dd..38fb9af 100644 --- a/docs/architecture/container-control.md +++ b/docs/architecture/container-control.md @@ -27,10 +27,10 @@ React ──> control-plane ── /api/browsers ──> docker-gateway ──> - 镜像、命令、网络、挂载和资源限制均由网关固定,外部输入只有受校验的名称和数字 seed; - 启停和删除前必须同时匹配固定名称前缀及 `io.creatorhub.managed`、`io.creatorhub.runtime-id` 标签; - 动态容器使用只读根文件系统、非 root 镜像、全部 capability drop、`no-new-privileges`、CPU/内存/PID 限制,且无宿主机端口和目录挂载; -- 控制面只绑定 `127.0.0.1`,控制网络为固定名称的 Compose internal 网络;浏览器 bridge 关闭 ICC,阻止运行时直接互访,并按 ownership、role、driver、Internal 和完整 options 失败关闭校验,且拒绝复用 control 网络; +- 控制面发布到宿主机所有网卡;控制网络为固定名称的 Compose 网络;浏览器 bridge 按 ownership、role、driver、Internal 失败关闭校验,且拒绝复用 control 网络; - 浏览器镜像固定为审核过的 immutable digest;运行时创建请求同时固定非 root `1000:1000` 与镜像入口,构建阶段和最终基础镜像也均锁定 digest。 -网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。当前控制面没有认证或 CSRF 防护,只支持可信单用户从 localhost 访问。进入共享或生产环境前,必须先补认证与 CSRF 边界,并把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。 +网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。开发阶段控制面不做认证或访问限制,安全由部署者自行把控。 ## 运行 @@ -41,9 +41,9 @@ docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0d DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` -打开 。浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7`。阶段 A 网络默认失败关闭且不能访问外网;后续授权沙箱必须先设计受管出口,再调整 `browser` 网络。 +打开 ,局域网内用宿主机 IP 访问同一端口。浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7`。浏览器容器可访问外网。 -创建成功但启动失败时,网关会立即删除失败容器并保留命名 Profile 卷,允许同名请求安全重试。跨容器隔离回归可用 `CREATORHUB_DOCKER_INTEGRATION=1 go test -run TestDockerIntegrationBlocksCrossContainerTraffic ./cmd/docker-gateway` 运行。 +创建成功但启动失败时,网关会立即删除失败容器并保留命名 Profile 卷,允许同名请求安全重试。 ## 阶段 A 离线闭环 diff --git a/docs/deployment.md b/docs/deployment.md index 823f60a..8c2b5f3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # CreatorHub 部署 -本文档适用于当前阶段 A:在一台可信 Linux 主机上通过 Docker Compose 部署,且仅允许本机单用户访问。当前控制面没有认证或 CSRF 防护,PostgreSQL 使用仅限内部网络的 `trust` 认证;不要将服务反向代理到公网或共享网络。 +本文档适用于当前阶段 A:在一台 Linux 主机上通过 Docker Compose 部署。开发阶段不设访问限制,安全由部署者自行把控。 ## 部署内容 @@ -11,7 +11,7 @@ - `postgres`:PostgreSQL 17,数据保存在 `creatorhub_postgres` 命名卷; - 浏览器容器:由网关按需创建,使用固定摘要的指纹浏览器镜像,Profile 保存在 `creatorhub-profile-<运行时名称>` 命名卷。 -控制面只发布到 `127.0.0.1`。Compose 的 `creatorhub_control` 网络和网关创建的 `creatorhub_browser` 网络均为内部网络;阶段 A 的浏览器容器默认不能访问外网,也不能互相通信。 +控制面发布到宿主机所有网卡,局域网内可直接访问;浏览器容器可访问外网。 `creator-hub` 会等待 `docker-gateway` 健康检查通过后再启动。 ## 前置条件 @@ -34,7 +34,7 @@ docker compose config --quiet ## 首次部署 -以下命令应在同一个 shell、仓库根目录执行。`DOCKER_GID` 必须与宿主机 Docker socket 的组一致;`CREATORHUB_PORT` 仅控制本机监听端口。 +以下命令应在同一个 shell、仓库根目录执行。`DOCKER_GID` 必须与宿主机 Docker socket 的组一致;`CREATORHUB_PORT` 控制控制面的宿主机端口。 ```bash export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)" @@ -80,7 +80,7 @@ Compose 部署时通常只需设置以下宿主机变量: | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `CREATORHUB_PORT` | `8080` | 映射到 `127.0.0.1` 的控制面端口 | +| `CREATORHUB_PORT` | `8080` | 控制面宿主机端口,局域网可访问 | | `DOCKER_GID` | `999` | Docker socket 的宿主机组 ID;必须按实际值设置 | 服务本身支持并校验以下环境变量;`compose.yaml` 已提供当前部署所需的值: @@ -97,7 +97,7 @@ Compose 部署时通常只需设置以下宿主机变量: | `docker-gateway` | `BROWSER_NETWORK` | `creatorhub_browser` | | `docker-gateway` | `LOG_LEVEL` | 默认 `info` | -不要把凭据写入仓库或 Compose 文件。共享或生产部署必须先补充认证、CSRF 防护和独立 Docker daemon/VM(或 Docker authorization plugin),并将 PostgreSQL 改为强认证。 +不要把凭据写入仓库或 Compose 文件。 ## 更新与回滚