From f24ed999cb6df0a7345d65d128addbdef2c3b0be Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 27 Aug 2026 18:45:48 +0800 Subject: [PATCH] HH-743: harden browser image and lifecycle (#5) --- Dockerfile | 6 +- README.md | 3 +- cmd/control-plane/main.go | 22 ++-- cmd/control-plane/main_test.go | 33 ++++++ cmd/docker-gateway/main.go | 42 ++++--- cmd/docker-gateway/main_test.go | 147 +++++++++++++++++++++++++ compose.yaml | 1 - docs/architecture/container-control.md | 10 +- 8 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 cmd/control-plane/main_test.go diff --git a/Dockerfile b/Dockerfile index 45105e9..896dd8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,18 @@ -FROM node:22-alpine AS web +FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS web WORKDIR /src/web COPY web/package*.json ./ RUN npm ci COPY web/ ./ RUN npm run build -FROM golang:1.26-alpine AS go +FROM golang:1.26-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS go WORKDIR /src COPY go.mod ./ COPY cmd/ ./cmd/ RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/control-plane ./cmd/control-plane \ && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway -FROM alpine:3.22 +FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app WORKDIR /app COPY --from=go /out/control-plane /out/docker-gateway /app/ diff --git a/README.md b/README.md index 0e527e3..515030d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 需要 Docker Compose。控制面仅绑定本机;浏览器网络在阶段 A 默认禁止外网访问: ```bash -docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215 +docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` @@ -28,6 +28,7 @@ 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/control-plane/main.go b/cmd/control-plane/main.go index a3005c6..105d565 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -16,7 +16,17 @@ func main() { if err != nil { log.Fatal(err) } + server := &http.Server{ + Addr: env("LISTEN_ADDR", ":8080"), + Handler: newHandler(gateway, env("WEB_DIR", "web/dist")), + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("CreatorHub control plane listening on %s", server.Addr) + log.Fatal(server.ListenAndServe()) +} +func newHandler(gateway *url.URL, webDirectory string) http.Handler { proxy := httputil.NewSingleHostReverseProxy(gateway) originalDirector := proxy.Director proxy.Director = func(request *http.Request) { @@ -31,16 +41,8 @@ func main() { mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusNoContent) }) - mux.Handle("/", spaHandler(env("WEB_DIR", "web/dist"))) - - server := &http.Server{ - Addr: env("LISTEN_ADDR", ":8080"), - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - IdleTimeout: 60 * time.Second, - } - log.Printf("CreatorHub control plane listening on %s", server.Addr) - log.Fatal(server.ListenAndServe()) + mux.Handle("/", spaHandler(webDirectory)) + return mux } func spaHandler(directory string) http.Handler { diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go new file mode 100644 index 0000000..299b914 --- /dev/null +++ b/cmd/control-plane/main_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestAPIProxyPreservesMethodPathAndStatus(t *testing.T) { + requests := make(chan *http.Request, 1) + gateway := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + requests <- request + response.WriteHeader(http.StatusServiceUnavailable) + })) + defer gateway.Close() + + upstream, err := url.Parse(gateway.URL) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/browsers/account-a/start", nil) + response := httptest.NewRecorder() + newHandler(upstream, t.TempDir()).ServeHTTP(response, request) + + proxied := <-requests + if proxied.Method != http.MethodPost || proxied.URL.Path != "/v1/browsers/account-a/start" { + t.Fatalf("unexpected proxy request %s %s", proxied.Method, proxied.URL.Path) + } + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("expected upstream status 503, got %d", response.Code) + } +} diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go index 050fa8a..27fbd96 100644 --- a/cmd/docker-gateway/main.go +++ b/cmd/docker-gateway/main.go @@ -19,6 +19,9 @@ import ( ) const ( + browserImage = "git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7" + browserUser = "1000:1000" + browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh" managedLabel = "io.creatorhub.managed" idLabel = "io.creatorhub.runtime-id" networkRoleLabel = "io.creatorhub.network-role" @@ -30,6 +33,8 @@ const ( var runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) var networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) +var errInvalidRuntimeID = errors.New("invalid runtime id") +var errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub") type config struct { image string @@ -61,11 +66,6 @@ type browser struct { } func main() { - image := os.Getenv("BROWSER_IMAGE") - if image == "" || strings.HasSuffix(image, ":latest") || !strings.Contains(image, ":") { - log.Fatal("BROWSER_IMAGE must be a pinned image tag") - } - transport := &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", env("DOCKER_SOCKET", "/var/run/docker.sock")) @@ -79,7 +79,7 @@ func main() { if err := docker.ensureInternalNetwork(network); err != nil { log.Fatal(err) } - handler := newGateway(docker, config{image: image, network: network}) + handler := newGateway(docker, config{image: browserImage, network: network}) server := &http.Server{ Addr: env("LISTEN_ADDR", ":8081"), @@ -161,9 +161,11 @@ func (api gateway) create(response http.ResponseWriter, request *http.Request) { pidsLimit := int64(512) payload := map[string]any{ - "Image": api.config.image, - "Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"}, - "Env": []string{"REMOTE_DEBUGGING_PORT=9222"}, + "Image": api.config.image, + "User": browserUser, + "Entrypoint": []string{browserEntrypoint}, + "Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"}, + "Env": []string{"REMOTE_DEBUGGING_PORT=9222"}, "Labels": map[string]string{ managedLabel: "true", idLabel: input.Name, @@ -208,7 +210,12 @@ func (api gateway) create(response http.ResponseWriter, request *http.Request) { return } if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil { - writeError(response, http.StatusBadGateway, fmt.Errorf("container created but did not start: %w", err)) + cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent) + if cleanupErr != nil { + writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr)) + return + } + writeError(response, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err)) return } writeJSON(response, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name}) @@ -254,7 +261,7 @@ func (api gateway) remove(response http.ResponseWriter, request *http.Request) { func (api gateway) requireManaged(id string) error { if !runtimeIDPattern.MatchString(id) { - return errors.New("invalid runtime id") + return errInvalidRuntimeID } result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil) if err != nil { @@ -276,7 +283,7 @@ func (api gateway) requireManaged(id string) error { return fmt.Errorf("decode Docker inspect: %w", err) } if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id { - return errors.New("refusing to operate on a container not owned by CreatorHub") + return errUnmanagedContainer } return nil } @@ -364,13 +371,16 @@ func (docker dockerClient) ensureInternalNetwork(name string) error { } func statusFor(err error) int { - if errors.Is(err, os.ErrNotExist) { + switch { + case errors.Is(err, errInvalidRuntimeID): + return http.StatusBadRequest + case errors.Is(err, os.ErrNotExist): return http.StatusNotFound - } - if strings.Contains(err.Error(), "refusing") { + case errors.Is(err, errUnmanagedContainer): return http.StatusForbidden + default: + return http.StatusBadGateway } - return http.StatusBadRequest } func forwardDockerError(response http.ResponseWriter, result *http.Response) { diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go index 09a550f..3142ac2 100644 --- a/cmd/docker-gateway/main_test.go +++ b/cmd/docker-gateway/main_test.go @@ -1,11 +1,17 @@ package main import ( + "context" "encoding/json" + "fmt" + "net" "net/http" "net/http/httptest" + "os" + "os/exec" "strings" "testing" + "time" ) func TestGatewayCreatesConstrainedBrowser(t *testing.T) { @@ -43,6 +49,9 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) { if created["Image"] != "registry.example/browser:1.2.3" { t.Fatalf("gateway accepted an unexpected image: %#v", created["Image"]) } + if created["User"] != browserUser || created["Entrypoint"].([]any)[0] != browserEntrypoint { + t.Fatalf("runtime identity is not fixed: user=%#v entrypoint=%#v", created["User"], created["Entrypoint"]) + } host := created["HostConfig"].(map[string]any) if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true { t.Fatalf("missing container isolation: %#v", host) @@ -57,6 +66,101 @@ func TestGatewayCreatesConstrainedBrowser(t *testing.T) { } } +func TestGatewayRemovesFailedContainerAndPreservesProfile(t *testing.T) { + removed := false + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodPost && request.URL.Path == "/containers/create": + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"Id":"failed-id"}`)) + case request.Method == http.MethodPost && request.URL.Path == "/containers/failed-id/start": + http.Error(response, "start failed", http.StatusInternalServerError) + case request.Method == http.MethodDelete && request.URL.Path == "/containers/failed-id": + removed = request.URL.Query().Get("force") == "1" && request.URL.Query().Get("v") == "0" + response.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) + } + })) + defer docker.Close() + + handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{image: browserImage, network: "creatorhub_browser"}) + request := httptest.NewRequest(http.MethodPost, "/v1/browsers", strings.NewReader(`{"name":"account-a","seed":1000}`)) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusBadGateway || !removed { + t.Fatalf("expected failed container cleanup with preserved volume, status=%d removed=%v body=%s", response.Code, removed, response.Body.String()) + } +} + +func TestGatewayListsBrowsers(t *testing.T) { + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/containers/json" { + t.Fatalf("unexpected Docker request %s %s", request.Method, request.URL.String()) + } + _, _ = response.Write([]byte(`[{"Id":"container-id","State":"running","Status":"Up","Labels":{"io.creatorhub.runtime-id":"account-a"}}]`)) + })) + defer docker.Close() + + handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/browsers", nil)) + + var browsers []browser + if response.Code != http.StatusOK || json.NewDecoder(response.Body).Decode(&browsers) != nil || len(browsers) != 1 || browsers[0].Name != "account-a" { + t.Fatalf("unexpected list response status=%d body=%s", response.Code, response.Body.String()) + } +} + +func TestGatewayLifecycle(t *testing.T) { + tests := []struct { + method string + path string + dockerPath string + }{ + {http.MethodPost, "/v1/browsers/account-a/start", "/containers/creatorhub-browser-account-a/start"}, + {http.MethodPost, "/v1/browsers/account-a/stop", "/containers/creatorhub-browser-account-a/stop"}, + {http.MethodDelete, "/v1/browsers/account-a", "/containers/creatorhub-browser-account-a"}, + } + for _, test := range tests { + t.Run(test.method+" "+test.path, func(t *testing.T) { + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodGet { + _, _ = response.Write([]byte(`{"Config":{"Labels":{"io.creatorhub.managed":"true","io.creatorhub.runtime-id":"account-a"}}}`)) + return + } + if request.URL.Path != test.dockerPath { + t.Fatalf("unexpected Docker path %s", request.URL.String()) + } + response.WriteHeader(http.StatusNoContent) + })) + defer docker.Close() + + handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil)) + if response.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", response.Code, response.Body.String()) + } + }) + } +} + +func TestGatewayMapsDockerServiceFailureToBadGateway(t *testing.T) { + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + http.Error(response, "daemon unavailable", http.StatusInternalServerError) + })) + defer docker.Close() + + handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/browsers/account-a", nil)) + if response.Code != http.StatusBadGateway { + t.Fatalf("expected 502 for Docker failure, got %d: %s", response.Code, response.Body.String()) + } +} + func TestGatewayRefusesUnmanagedContainer(t *testing.T) { deleted := false docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { @@ -183,3 +287,46 @@ func TestEnsureInternalNetworkRejectsControlNetwork(t *testing.T) { t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err) } } + +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 c7aee5e..d17fbdc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,7 +18,6 @@ services: build: . command: ["/app/docker-gateway"] environment: - BROWSER_IMAGE: ${BROWSER_IMAGE:-git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215} BROWSER_NETWORK: creatorhub_browser volumes: - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md index b35a9da..8efde0f 100644 --- a/docs/architecture/container-control.md +++ b/docs/architecture/container-control.md @@ -27,17 +27,19 @@ React ── /api/browsers ──> control-plane ── /v1/browsers ──> doc - 启停和删除前必须同时匹配固定名称前缀及 `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 网络; -- `BROWSER_IMAGE` 必须是固定 tag,拒绝 `latest`。 +- 浏览器镜像固定为审核过的 immutable digest;运行时创建请求同时固定非 root `1000:1000` 与镜像入口,构建阶段和最终基础镜像也均锁定 digest。 -网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。进入共享或生产环境前,应把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。 +网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。当前控制面没有认证或 CSRF 防护,只支持可信单用户从 localhost 访问。进入共享或生产环境前,必须先补认证与 CSRF 边界,并把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。 ## 运行 Docker socket 的 GID 因宿主机而异: ```bash -docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215 +docker pull git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7 DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` -打开 。默认浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215`,可通过 `BROWSER_IMAGE` 改为其他已审核的固定 tag。阶段 A 网络默认失败关闭且不能访问外网;后续授权沙箱必须先设计受管出口,再调整 `browser` 网络。 +打开 。浏览器镜像固定为 `git.ipao.vip/rogee/fingerprint-chromium@sha256:b9f23b8e3ac640174db0dfa49e9095fe7eb06f5db55a4e7550d979b35ff3a1b7`。阶段 A 网络默认失败关闭且不能访问外网;后续授权沙箱必须先设计受管出口,再调整 `browser` 网络。 + +创建成功但启动失败时,网关会立即删除失败容器并保留命名 Profile 卷,允许同名请求安全重试。跨容器隔离回归可用 `CREATORHUB_DOCKER_INTEGRATION=1 go test -run TestDockerIntegrationBlocksCrossContainerTraffic ./cmd/docker-gateway` 运行。