From 4b054d6f04ec652e8ac2cb2b4ea9aa76ed15a17d Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 27 Aug 2026 18:07:34 +0800 Subject: [PATCH] HH-738: add constrained browser container control (#4) --- .dockerignore | 4 + .gitignore | 2 + Dockerfile | 23 + README.md | 23 +- cmd/control-plane/main.go | 63 ++ cmd/docker-gateway/main.go | 400 ++++++++++++ cmd/docker-gateway/main_test.go | 185 ++++++ compose.yaml | 38 ++ docs/architecture/container-control.md | 43 ++ go.mod | 3 + web/index.html | 13 + web/package-lock.json | 860 +++++++++++++++++++++++++ web/package.json | 18 + web/src/main.jsx | 149 +++++ web/src/styles.css | 93 +++ web/vite.config.js | 9 + 16 files changed, 1925 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 cmd/control-plane/main.go create mode 100644 cmd/docker-gateway/main.go create mode 100644 cmd/docker-gateway/main_test.go create mode 100644 compose.yaml create mode 100644 docs/architecture/container-control.md create mode 100644 go.mod create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/main.jsx create mode 100644 web/src/styles.css create mode 100644 web/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f37350 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitea +web/node_modules +web/dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..162e443 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +web/dist/ +web/node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..45105e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine 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 +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 +RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app +WORKDIR /app +COPY --from=go /out/control-plane /out/docker-gateway /app/ +COPY --from=web /src/web/dist /app/web +USER 65532:65532 +ENV WEB_DIR=/app/web +EXPOSE 8080 8081 +CMD ["/app/control-plane"] diff --git a/README.md b/README.md index 431acc4..0e527e3 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,32 @@ ## 当前阶段 -项目处于规划与初始化阶段。功能参考公开上游项目独立实现,不直接复制其代码。 +项目处于阶段 A 技术验证。首个可运行切片提供浏览器容器的创建、列表、启停和回收,功能参考公开上游项目独立实现,不直接复制其代码。 详细的五领域优先级、系统边界、合规知识库与分阶段验证方案见 [《合规产品规划与上游调研》](docs/product/compliance-product-plan.md)。 +## 本地运行 + +需要 Docker Compose。控制面仅绑定本机;浏览器网络在阶段 A 默认禁止外网访问: + +```bash +docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215 +DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build +``` + +打开 。架构、API 契约、失败语义和 `docker.sock` 风险边界见 +[《浏览器容器控制面》](docs/architecture/container-control.md)。 + +最小验证: + +```bash +go test ./... +npm --prefix web ci +npm --prefix web run build +docker compose config --quiet +``` + ## 合规边界 - 自动化行为必须遵守目标平台规则、适用法律和账号授权范围。 diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go new file mode 100644 index 0000000..a3005c6 --- /dev/null +++ b/cmd/control-plane/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +func main() { + gateway, err := url.Parse(env("DOCKER_GATEWAY_URL", "http://127.0.0.1:8081")) + if err != nil { + log.Fatal(err) + } + + proxy := httputil.NewSingleHostReverseProxy(gateway) + originalDirector := proxy.Director + proxy.Director = func(request *http.Request) { + originalDirector(request) + request.URL.Path = "/v1/browsers" + strings.TrimPrefix(request.URL.Path, "/api/browsers") + request.Host = gateway.Host + } + + mux := http.NewServeMux() + mux.Handle("/api/browsers", proxy) + mux.Handle("/api/browsers/", proxy) + 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()) +} + +func spaHandler(directory string) http.Handler { + files := http.FileServer(http.Dir(directory)) + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + name := filepath.Join(directory, filepath.Clean(strings.TrimPrefix(request.URL.Path, "/"))) + if info, err := os.Stat(name); err == nil && !info.IsDir() { + files.ServeHTTP(response, request) + return + } + http.ServeFile(response, request, filepath.Join(directory, "index.html")) + }) +} + +func env(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/cmd/docker-gateway/main.go b/cmd/docker-gateway/main.go new file mode 100644 index 0000000..050fa8a --- /dev/null +++ b/cmd/docker-gateway/main.go @@ -0,0 +1,400 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + managedLabel = "io.creatorhub.managed" + idLabel = "io.creatorhub.runtime-id" + networkRoleLabel = "io.creatorhub.network-role" + browserNetworkRole = "browser" + controlNetworkName = "creatorhub_control" + bridgeICCOption = "com.docker.network.bridge.enable_icc" + namePrefix = "creatorhub-browser-" +) + +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}$`) + +type config struct { + image string + network string +} + +type dockerClient struct { + baseURL string + client *http.Client +} + +type gateway struct { + docker dockerClient + config config +} + +type createRequest struct { + Name string `json:"name"` + Seed int64 `json:"seed"` +} + +type browser struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Status string `json:"status"` + Profile string `json:"profile"` + Endpoint string `json:"endpoint"` +} + +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")) + }, + } + docker := dockerClient{ + baseURL: "http://docker/v1.43", + client: &http.Client{Transport: transport, Timeout: 30 * time.Second}, + } + network := env("BROWSER_NETWORK", "creatorhub_browser") + if err := docker.ensureInternalNetwork(network); err != nil { + log.Fatal(err) + } + handler := newGateway(docker, config{image: image, network: network}) + + server := &http.Server{ + Addr: env("LISTEN_ADDR", ":8081"), + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("restricted Docker gateway listening on %s", server.Addr) + log.Fatal(server.ListenAndServe()) +} + +func newGateway(client dockerClient, cfg config) http.Handler { + api := gateway{docker: client, config: cfg} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /v1/browsers", api.list) + mux.HandleFunc("POST /v1/browsers", api.create) + mux.HandleFunc("POST /v1/browsers/{id}/{action}", api.changeState) + mux.HandleFunc("DELETE /v1/browsers/{id}", api.remove) + return mux +} + +func (api gateway) list(response http.ResponseWriter, _ *http.Request) { + filters, _ := json.Marshal(map[string][]string{"label": {managedLabel + "=true"}}) + result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil) + if err != nil { + writeError(response, http.StatusBadGateway, err) + return + } + defer result.Body.Close() + if result.StatusCode != http.StatusOK { + forwardDockerError(response, result) + return + } + + var containers []struct { + ID string `json:"Id"` + State string `json:"State"` + Status string `json:"Status"` + Labels map[string]string `json:"Labels"` + } + if err := json.NewDecoder(result.Body).Decode(&containers); err != nil { + writeError(response, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err)) + return + } + + browsers := make([]browser, 0, len(containers)) + for _, container := range containers { + id := container.Labels[idLabel] + if !runtimeIDPattern.MatchString(id) { + continue + } + browsers = append(browsers, browser{ + ID: container.ID, + Name: id, + State: container.State, + Status: container.Status, + Profile: "creatorhub-profile-" + id, + Endpoint: "http://" + namePrefix + id + ":9222", + }) + } + writeJSON(response, http.StatusOK, browsers) +} + +func (api gateway) create(response http.ResponseWriter, request *http.Request) { + var input createRequest + decoder := json.NewDecoder(http.MaxBytesReader(response, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + writeError(response, http.StatusBadRequest, errors.New("body must contain only name and numeric seed")) + return + } + if !runtimeIDPattern.MatchString(input.Name) || input.Seed < 1 || input.Seed > 2147483647 { + writeError(response, http.StatusBadRequest, errors.New("name must match [a-z0-9][a-z0-9-]{0,31} and seed must be 1..2147483647")) + return + } + + 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"}, + "Labels": map[string]string{ + managedLabel: "true", + idLabel: input.Name, + }, + "ExposedPorts": map[string]any{"9222/tcp": map[string]any{}}, + "HostConfig": map[string]any{ + "NetworkMode": api.config.network, + "ReadonlyRootfs": true, + "CapDrop": []string{"ALL"}, + "SecurityOpt": []string{"no-new-privileges"}, + "PidsLimit": &pidsLimit, + "Memory": int64(1 << 30), + "NanoCpus": int64(2_000_000_000), + "Tmpfs": map[string]string{ + "/tmp": "rw,nosuid,nodev,noexec,mode=1777,size=256m", + "/tmp/.X11-unix": "rw,nosuid,nodev,noexec,mode=1777,size=1m", + "/dev/shm": "rw,nosuid,nodev,noexec,size=256m", + "/home/ubuntu": "rw,nosuid,nodev,noexec,uid=1000,gid=1000,mode=700,size=64m", + }, + "Mounts": []map[string]any{{ + "Type": "volume", + "Source": "creatorhub-profile-" + input.Name, + "Target": "/data", + }}, + }, + } + result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Name), payload) + if err != nil { + writeError(response, http.StatusBadGateway, err) + return + } + defer result.Body.Close() + if result.StatusCode != http.StatusCreated { + forwardDockerError(response, result) + return + } + var created struct { + ID string `json:"Id"` + } + if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" { + writeError(response, http.StatusBadGateway, errors.New("Docker returned an invalid container id")) + 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)) + return + } + writeJSON(response, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name}) +} + +func (api gateway) changeState(response http.ResponseWriter, request *http.Request) { + id := request.PathValue("id") + action := request.PathValue("action") + if err := api.requireManaged(id); err != nil { + writeError(response, statusFor(err), err) + return + } + var path string + switch action { + case "start": + path = "/containers/" + url.PathEscape(namePrefix+id) + "/start" + case "stop": + path = "/containers/" + url.PathEscape(namePrefix+id) + "/stop?t=10" + default: + writeError(response, http.StatusNotFound, errors.New("unknown action")) + return + } + if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil { + writeError(response, http.StatusBadGateway, err) + return + } + response.WriteHeader(http.StatusNoContent) +} + +func (api gateway) remove(response http.ResponseWriter, request *http.Request) { + id := request.PathValue("id") + if err := api.requireManaged(id); err != nil { + writeError(response, statusFor(err), err) + return + } + path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0" + if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil { + writeError(response, http.StatusBadGateway, err) + return + } + response.WriteHeader(http.StatusNoContent) +} + +func (api gateway) requireManaged(id string) error { + if !runtimeIDPattern.MatchString(id) { + return errors.New("invalid runtime id") + } + result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil) + if err != nil { + return err + } + defer result.Body.Close() + if result.StatusCode == http.StatusNotFound { + return os.ErrNotExist + } + if result.StatusCode != http.StatusOK { + return fmt.Errorf("Docker inspect returned %s", result.Status) + } + var inspected struct { + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"Config"` + } + if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil { + 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 nil +} + +func (docker dockerClient) request(method, path string, payload any) (*http.Response, error) { + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + body = bytes.NewReader(encoded) + } + request, err := http.NewRequest(method, docker.baseURL+path, body) + if err != nil { + return nil, err + } + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + return docker.client.Do(request) +} + +func (docker dockerClient) expect(method, path string, payload any, allowed ...int) error { + response, err := docker.request(method, path, payload) + if err != nil { + return err + } + defer response.Body.Close() + for _, status := range allowed { + if response.StatusCode == status { + return nil + } + } + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message))) +} + +func (docker dockerClient) ensureInternalNetwork(name string) error { + if !networkNamePattern.MatchString(name) { + return errors.New("BROWSER_NETWORK is invalid") + } + if name == controlNetworkName { + return errors.New("BROWSER_NETWORK must not reuse the control network") + } + response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil) + if err != nil { + return fmt.Errorf("inspect browser network: %w", err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusOK { + var network struct { + Name string `json:"Name"` + Driver string `json:"Driver"` + 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) + } + return nil + } + if response.StatusCode != http.StatusNotFound { + return fmt.Errorf("inspect browser network returned %s", response.Status) + } + return docker.expect(http.MethodPost, "/networks/create", map[string]any{ + "Name": name, + "CheckDuplicate": true, + "Driver": "bridge", + "Internal": true, + "Options": map[string]string{bridgeICCOption: "false"}, + "Labels": map[string]string{ + managedLabel: "true", + networkRoleLabel: browserNetworkRole, + }, + }, http.StatusCreated) +} + +func statusFor(err error) int { + if errors.Is(err, os.ErrNotExist) { + return http.StatusNotFound + } + if strings.Contains(err.Error(), "refusing") { + return http.StatusForbidden + } + return http.StatusBadRequest +} + +func forwardDockerError(response http.ResponseWriter, result *http.Response) { + message, _ := io.ReadAll(io.LimitReader(result.Body, 4096)) + status := http.StatusBadGateway + if result.StatusCode == http.StatusConflict { + status = http.StatusConflict + } + writeError(response, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message)))) +} + +func writeError(response http.ResponseWriter, status int, err error) { + writeJSON(response, status, map[string]string{"error": err.Error()}) +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(status) + _ = json.NewEncoder(response).Encode(value) +} + +func env(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/cmd/docker-gateway/main_test.go b/cmd/docker-gateway/main_test.go new file mode 100644 index 0000000..09a550f --- /dev/null +++ b/cmd/docker-gateway/main_test.go @@ -0,0 +1,185 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGatewayCreatesConstrainedBrowser(t *testing.T) { + var created map[string]any + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/create": + if got := request.URL.Query().Get("name"); got != namePrefix+"account-a" { + t.Fatalf("unexpected container name %q", got) + } + if err := json.NewDecoder(request.Body).Decode(&created); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusCreated) + _, _ = response.Write([]byte(`{"Id":"container-id"}`)) + case request.Method == http.MethodPost && request.URL.Path == "/v1.43/containers/container-id/start": + 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 + "/v1.43", client: docker.Client()}, config{ + image: "registry.example/browser:1.2.3", + 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.StatusCreated { + t.Fatalf("expected 201, got %d: %s", response.Code, response.Body.String()) + } + if created["Image"] != "registry.example/browser:1.2.3" { + t.Fatalf("gateway accepted an unexpected image: %#v", created["Image"]) + } + host := created["HostConfig"].(map[string]any) + if host["NetworkMode"] != "creatorhub_browser" || host["ReadonlyRootfs"] != true { + t.Fatalf("missing container isolation: %#v", host) + } + tmpfs := host["Tmpfs"].(map[string]any) + if tmpfs["/tmp/.X11-unix"] == nil || tmpfs["/home/ubuntu"] == nil { + t.Fatalf("missing writable runtime paths: %#v", tmpfs) + } + labels := created["Labels"].(map[string]any) + if labels[managedLabel] != "true" || labels[idLabel] != "account-a" { + t.Fatalf("missing ownership labels: %#v", labels) + } +} + +func TestGatewayRefusesUnmanagedContainer(t *testing.T) { + deleted := false + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + _, _ = response.Write([]byte(`{"Config":{"Labels":{}}}`)) + case http.MethodDelete: + deleted = true + response.WriteHeader(http.StatusNoContent) + } + })) + defer docker.Close() + + handler := newGateway(dockerClient{baseURL: docker.URL, client: docker.Client()}, config{}) + request := httptest.NewRequest(http.MethodDelete, "/v1/browsers/foreign", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden || deleted { + t.Fatalf("expected unmanaged container to be rejected, status=%d deleted=%v", response.Code, deleted) + } +} + +func TestEnsureInternalNetwork(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"` + } + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + response.WriteHeader(http.StatusNotFound) + case http.MethodPost: + if err := json.NewDecoder(request.Body).Decode(&created); err != nil { + t.Fatal(err) + } + response.WriteHeader(http.StatusCreated) + } + })) + defer docker.Close() + + client := dockerClient{baseURL: docker.URL, client: docker.Client()} + if err := client.ensureInternalNetwork("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 || + created.Labels[managedLabel] != "true" || created.Labels[networkRoleLabel] != browserNetworkRole { + t.Fatalf("network is not isolated: %#v", created) + } +} + +func TestEnsureInternalNetworkRejectsUnsafeExistingNetwork(t *testing.T) { + valid := map[string]any{ + "Name": "creatorhub_browser", + "Driver": "bridge", + "Internal": true, + "Attachable": false, + "Ingress": false, + "Options": map[string]string{bridgeICCOption: "false"}, + "Labels": map[string]string{ + managedLabel: "true", + networkRoleLabel: browserNetworkRole, + }, + } + validDocker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(response).Encode(valid) + })) + 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) + } + + 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 }, + "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) { + network["Labels"] = map[string]string{networkRoleLabel: browserNetworkRole} + }, + "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 { + t.Run(name, func(t *testing.T) { + network := make(map[string]any, len(valid)) + for key, value := range valid { + network[key] = value + } + mutate(network) + docker := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(response).Encode(network) + })) + defer docker.Close() + + client := dockerClient{baseURL: docker.URL, client: docker.Client()} + if err := client.ensureInternalNetwork("creatorhub_browser"); err == nil { + t.Fatal("expected unsafe existing network to be rejected") + } + }) + } +} + +func TestEnsureInternalNetworkRejectsControlNetwork(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 { + t.Fatalf("expected control network to be rejected before Docker request, requested=%v err=%v", requested, err) + } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c7aee5e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,38 @@ +services: + creator-hub: + build: . + environment: + DOCKER_GATEWAY_URL: http://docker-gateway:8081 + ports: + - "127.0.0.1:${CREATORHUB_PORT:-8080}:8080" + read_only: true + tmpfs: + - /tmp:size=16m,noexec,nosuid,nodev + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + depends_on: [docker-gateway] + networks: [control] + restart: unless-stopped + + docker-gateway: + 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 + group_add: + - "${DOCKER_GID:-999}" + read_only: true + tmpfs: + - /tmp:size=16m,noexec,nosuid,nodev + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + networks: [control] + restart: unless-stopped + +networks: + control: + name: creatorhub_control + internal: true diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md new file mode 100644 index 0000000..b35a9da --- /dev/null +++ b/docs/architecture/container-control.md @@ -0,0 +1,43 @@ +# 浏览器容器控制面 + +## 技术选型 + +- 前端:React + Vite。阶段 A 只有一个运行环境页面,不引入路由、状态库或组件库。 +- 后端:Go 标准库模块化单体。控制面提供同源 API 和静态文件,受限网关单独封装 Docker Engine API。 +- 数据:本任务不引入业务数据库;Docker 容器标签是运行态事实,Profile 使用命名卷持久化。账号、任务和审计实体仍按产品规划在后续阶段落入 Postgres。 +- 部署:Docker Compose 启动控制面和受限网关;浏览器容器由网关动态创建。 + +## 调用链与契约 + +```text +React ── /api/browsers ──> control-plane ── /v1/browsers ──> docker-gateway ──> docker.sock + │ + └─> browser container +``` + +`POST /api/browsers` 接受 `{name, seed}` 并创建后启动;`GET /api/browsers` 列出受管容器;`POST /api/browsers/{name}/start|stop` 改变状态;`DELETE /api/browsers/{name}` 仅回收容器,保留 Profile 数据卷。名称唯一约束由 Docker 容器名保证,重复创建返回冲突;启停接受 Docker 的幂等响应,不自动重试未知结果。 + +## docker.sock 安全边界 + +将 socket 以只读文件挂载**不会**限制 Docker API 的写操作;拥有 socket 等价于拥有宿主机 root 权限。因此: + +- 只有 `docker-gateway` 挂载 socket,控制面和浏览器容器均不可见;网关只加入 control 网络,浏览器不能连接网关; +- 网关只暴露四个面向领域的路由,不提供通用 Docker 代理; +- 镜像、命令、网络、挂载和资源限制均由网关固定,外部输入只有受校验的名称和数字 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 网络; +- `BROWSER_IMAGE` 必须是固定 tag,拒绝 `latest`。 + +网关自身一旦被攻破,socket 仍允许接管宿主机;应用内校验不能消除这个平台级风险。进入共享或生产环境前,应把浏览器调度迁到独立 Docker daemon/VM,或使用宿主机 Docker authorization plugin 做第二层强制授权,不应把 socket 暴露给公网服务。 + +## 运行 + +Docker socket 的 GID 因宿主机而异: + +```bash +docker pull git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215 +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` 网络。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cdac8c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.ipao.vip/rogee/creator-hub + +go 1.26 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..602f9c4 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CreatorHub · 运行环境 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5d81881 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,860 @@ +{ + "name": "creator-hub-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "creator-hub-web", + "version": "0.1.0", + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "6.1.0", + "vite": "8.2.2" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..034d670 --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "creator-hub-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "6.1.0", + "vite": "8.2.2" + } +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..37abc7f --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,149 @@ +import { StrictMode, useEffect, useState } from 'react' +import { createRoot } from 'react-dom/client' +import './styles.css' + +const statusLabels = { + created: '已创建', + running: '运行中', + exited: '已停止', + restarting: '重启中', + paused: '已暂停', +} + +async function api(path = '', options) { + const response = await fetch(`/api/browsers${path}`, options) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error(body.error || `请求失败 (${response.status})`) + } + return response.status === 204 ? null : response.json() +} + +function Icon({ name }) { + const paths = { + runtime: <>, + audit: <>, + shield: , + play: , + stop: , + trash: <>, + } + return +} + +function RuntimeRow({ runtime, busy, onAction }) { + const running = runtime.state === 'running' + return ( + + {runtime.name}{runtime.status} + {statusLabels[runtime.state] || runtime.state} + {runtime.profile} + {runtime.endpoint} + + + + + + + ) +} + +function App() { + const [runtimes, setRuntimes] = useState([]) + const [name, setName] = useState('') + const [seed, setSeed] = useState('1000') + const [busy, setBusy] = useState('') + const [error, setError] = useState('') + + async function refresh(signal) { + try { + setRuntimes(await api('', { signal })) + setError('') + } catch (reason) { + if (reason.name !== 'AbortError') setError(reason.message) + } + } + + useEffect(() => { + const controller = new AbortController() + refresh(controller.signal) + const timer = window.setInterval(() => refresh(controller.signal), 3000) + return () => { + window.clearInterval(timer) + controller.abort() + } + }, []) + + async function createRuntime(event) { + event.preventDefault() + setBusy('create') + setError('') + try { + await api('', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, seed: Number(seed) }), + }) + setName('') + await refresh() + } catch (reason) { + setError(reason.message) + } finally { + setBusy('') + } + } + + async function act(runtimeName, action) { + if (action === 'delete' && !window.confirm(`回收 ${runtimeName}?Profile 数据卷将保留。`)) return + setBusy(runtimeName) + setError('') + try { + await api(`/${runtimeName}${action === 'delete' ? '' : `/${action}`}`, { method: action === 'delete' ? 'DELETE' : 'POST' }) + await refresh() + } catch (reason) { + setError(reason.message) + } finally { + setBusy('') + } + } + + return ( +
+ +
+
+

运行环境

+

启动、停止并回收隔离的浏览器 Profile

+
+ +
+ + + +
+ +
+

浏览器运行环境

+ + + + {runtimes.map((runtime) => )} + +
环境状态ProfileCDP操作
+ {runtimes.length === 0 ?

暂无运行环境,创建第一个隔离 Profile。

: null} +
+ + {error ?

{error}

: null} +

Docker socket 仅由受限网关访问

+
+
+ ) +} + +createRoot(document.getElementById('root')).render() diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..617e96a --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,93 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #111827; + background: #ffffff; + font-synthesis: none; +} + +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; } +button, input { font: inherit; } +button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; } +.shell { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; } +.sidebar { padding: 30px 12px; color: #fff; background: #061a38; } +.brand { display: block; margin: 0 12px 38px; color: inherit; font-size: 28px; font-weight: 760; text-decoration: none; letter-spacing: -1px; } +.sidebar nav { display: grid; gap: 12px; } +.nav-item { display: flex; align-items: center; gap: 14px; min-height: 60px; padding: 0 18px; border-radius: 8px; color: #a9b7cc; font-size: 17px; font-weight: 650; text-decoration: none; } +.nav-item.active { color: #fff; background: #0866ef; } +.nav-item.disabled { opacity: .7; } +.icon { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; } +main { width: min(100%, 1280px); padding: 44px 36px 72px; } +header { margin-bottom: 30px; } +h1 { margin: 0 0 10px; font-size: 40px; line-height: 1.15; letter-spacing: -1.4px; } +header p { margin: 0; color: #667085; font-size: 18px; } +.create-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) 210px; gap: 28px; align-items: end; padding: 30px; border: 1px solid #d7dde7; border-radius: 9px; box-shadow: 0 4px 14px rgb(16 24 40 / 6%); } +label { display: grid; gap: 10px; font-size: 15px; font-weight: 650; } +input { width: 100%; height: 52px; padding: 0 14px; border: 1px solid #c7cfdb; border-radius: 7px; color: #111827; background: #fff; font-size: 16px; } +input::placeholder { color: #929daf; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 44px; padding: 0 14px; border: 1px solid; border-radius: 7px; background: #fff; font-size: 14px; font-weight: 650; cursor: pointer; } +.button .icon { width: 17px; height: 17px; } +.button:disabled { cursor: not-allowed; opacity: .4; } +.button-primary { height: 52px; border-color: #0866ef; color: #fff; background: #0866ef; font-size: 16px; } +.button-outline { border-color: #0866ef; color: #0866ef; } +.button-neutral { border-color: #aab4c3; color: #344054; } +.button-danger { border-color: #ef4444; color: #dc2626; } +.runtime-list { margin-top: 42px; overflow: hidden; border: 1px solid #d7dde7; border-radius: 9px; } +table { width: 100%; border-collapse: collapse; table-layout: fixed; } +th, td { padding: 23px 20px; border-bottom: 1px solid #d7dde7; text-align: left; vertical-align: middle; } +th { color: #1f2937; font-size: 14px; font-weight: 700; } +th:nth-child(1) { width: 19%; } th:nth-child(2) { width: 12%; } th:nth-child(3) { width: 17%; } th:nth-child(4) { width: 22%; } th:nth-child(5) { width: 30%; } +tbody tr:last-child td { border-bottom: 0; } +td strong, td small { display: block; } +td small { margin-top: 6px; color: #7a8699; } +code { color: #475467; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; overflow-wrap: anywhere; } +.state { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; } +.state::before { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; content: ""; } +.state-running { color: #079455; } .state-running::before { background: #12b76a; } +.state-exited { color: #d97706; } .state-exited::before { background: #f59e0b; } +.actions { display: flex; flex-wrap: wrap; gap: 8px; } +.empty { margin: 0; padding: 52px 20px; color: #667085; text-align: center; } +.error { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #fecaca; border-radius: 7px; color: #b42318; background: #fef2f2; } +.security-note { display: flex; align-items: center; gap: 12px; margin: 32px 0 0; padding: 19px 24px; border: 1px solid #bfdbfe; border-radius: 8px; color: #243b5a; background: #f8fbff; } +.security-note .icon { color: #164e85; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 1000px) { + .shell { grid-template-columns: 82px 1fr; } + .brand { margin: 4px 8px 38px; font-size: 0; } + .brand::before { font-size: 22px; content: "CH"; } + .nav-item { justify-content: center; padding: 0; font-size: 0; } + .create-form { grid-template-columns: 1fr 1fr; } + .button-primary { grid-column: 1 / -1; } + th:nth-child(4), td:nth-child(4) { display: none; } + th:nth-child(1) { width: 24%; } th:nth-child(2) { width: 18%; } th:nth-child(3) { width: 25%; } th:nth-child(5) { width: 33%; } +} + +@media (max-width: 700px) { + .shell { display: block; } + .sidebar { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; } + .brand { margin: 0; font-size: 20px; } + .brand::before { content: none; } + .sidebar nav { display: flex; } + .nav-item { min-height: 44px; padding: 0 14px; font-size: 14px; } + .nav-item.disabled { display: none; } + main { padding: 30px 18px 48px; } + h1 { font-size: 32px; } + .create-form { grid-template-columns: 1fr; padding: 22px; gap: 18px; } + .button-primary { grid-column: auto; } + .runtime-list { overflow: visible; border: 0; } + thead { display: none; } + tbody { display: grid; gap: 14px; } + tr { display: grid; gap: 12px; padding: 20px; border: 1px solid #d7dde7; border-radius: 8px; } + td, tbody tr:last-child td { display: grid; grid-template-columns: 90px 1fr; padding: 0; border: 0; } + td::before { color: #667085; font-size: 13px; content: attr(data-label); } + td:nth-child(4) { display: none; } + .actions { display: flex; padding-top: 8px; } + .actions::before { display: none; } +} + +@media (prefers-reduced-motion: no-preference) { + .button { transition: background-color .15s ease, border-color .15s ease, opacity .15s ease; } + .button:not(:disabled):hover { background: #eff6ff; } + .button-primary:not(:disabled):hover { background: #0759d4; } +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..a0fbd7e --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { '/api': 'http://127.0.0.1:8080' }, + }, +})