package main import ( "bytes" "context" "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "os/signal" "regexp" "strconv" "strings" "syscall" "time" "unicode/utf8" "github.com/gofiber/fiber/v3" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" ) const ( browserUser = "1000:1000" browserEntrypoint = "/usr/local/bin/docker-entrypoint.sh" managedLabel = "io.creatorhub.managed" idLabel = "io.creatorhub.runtime-id" nameLabel = "io.creatorhub.display-name" bindingVersionLabel = "io.creatorhub.binding-version" networkExitLabel = "io.creatorhub.network-exit-id" proxyPortLabel = "io.creatorhub.proxy-port" networkIDLabel = "io.creatorhub.network-id" networkRoleLabel = "io.creatorhub.network-role" gatewayMemberLabel = "io.creatorhub.gateway-member" browserNetworkRole = "browser" controlNetworkName = "creatorhub_control" namePrefix = "creatorhub-browser-" reservationPrefix = "creatorhub-reservation-" reservationLabel = "io.creatorhub.alias-reservation" reservationGenLabel = "io.creatorhub.reservation-generation" pullTimeout = 10 * time.Minute ) var ( runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) imageRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,300}$`) volumePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) ) var ( errInvalidRuntimeID = errors.New("invalid runtime id") errUnmanagedContainer = errors.New("refusing to operate on a container not owned by CreatorHub") errGenerationConflict = errors.New("container generation does not match request") errUnauthorized = errors.New("gateway token rejected") ) type serviceConfig struct { listenAddr string dockerSock string network string token string logLevel logrus.Level } type dockerClient struct { baseURL string client *http.Client // 常规 Docker 调用 slow *http.Client // 镜像拉取等长操作,不设整体超时 } type dockerAliasReservations struct { docker dockerClient self string } type tenantNetworkGeneration struct { ID string Name string Created bool ConnectedSelf bool ConnectedRuntime bool GatewayMembers []string SelfMember string RuntimeAttached bool } type dockerTenantNetwork struct { ID string `json:"Id"` Name string `json:"Name"` Driver string `json:"Driver"` Internal bool `json:"Internal"` Attachable bool `json:"Attachable"` Ingress bool `json:"Ingress"` Labels map[string]string `json:"Labels"` Containers map[string]struct { Name string `json:"Name"` IPv4Address string `json:"IPv4Address"` } `json:"Containers"` } type gateway struct { docker dockerClient network string self string token string browser restrictedBrowser proxies *memoryProxyRegistry locks *dockerAliasReservations } // createRequest 全量字段由平台下发;网关不做业务决策,只做输入合法性校验。 type createRequest struct { Alias string `json:"alias"` Name string `json:"name"` Image string `json:"image"` Cmd []string `json:"cmd"` Volume string `json:"volume"` BindingVersion int64 `json:"binding_version"` NetworkExitID string `json:"network_exit_id"` NetworkExit gatewayProxyExit `json:"network_exit"` Stopped bool `json:"stopped,omitempty"` } type gatewayProxyExit struct { Protocol string `json:"protocol"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` } type generationRequest struct { BindingVersion int64 `json:"binding_version"` RuntimeID string `json:"runtime_id"` NetworkID string `json:"network_id"` } type proxyRestoreRequest struct { BindingVersion int64 `json:"binding_version"` RuntimeID string `json:"runtime_id"` NetworkID string `json:"network_id"` NetworkExitID string `json:"network_exit_id"` NetworkExit gatewayProxyExit `json:"network_exit"` } type browser struct { ID string `json:"id"` Alias string `json:"alias"` Name string `json:"name"` State string `json:"state"` Status string `json:"status"` Endpoint string `json:"endpoint"` BindingVersion int64 `json:"binding_version"` NetworkExitID string `json:"network_exit_id"` NetworkID string `json:"network_id"` ProxyReady bool `json:"proxy_ready"` } func main() { logrus.SetFormatter(&logrus.JSONFormatter{}) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := newCommand().ExecuteContext(ctx); err != nil { logrus.WithField("service", "docker-gateway").WithError(err).Error("service stopped") os.Exit(1) } } func newCommand() *cobra.Command { command := &cobra.Command{ Use: "docker-gateway", Short: "Run the restricted CreatorHub Docker gateway", Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, RunE: func(command *cobra.Command, _ []string) error { cfg, err := loadConfig() if err != nil { return err } logrus.SetLevel(cfg.logLevel) return run(command, cfg) }, } return command } func loadConfig() (serviceConfig, error) { v := viper.New() v.SetDefault("listen_addr", ":8081") v.SetDefault("docker_socket", "/var/run/docker.sock") v.SetDefault("browser_network", "creatorhub_browser") v.SetDefault("log_level", "info") _ = v.BindEnv("listen_addr", "LISTEN_ADDR") _ = v.BindEnv("docker_socket", "DOCKER_SOCKET") _ = v.BindEnv("browser_network", "BROWSER_NETWORK") _ = v.BindEnv("gateway_token", "GATEWAY_TOKEN") _ = v.BindEnv("log_level", "LOG_LEVEL") level, err := logrus.ParseLevel(v.GetString("log_level")) if err != nil { return serviceConfig{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace") } cfg := serviceConfig{ listenAddr: strings.TrimSpace(v.GetString("listen_addr")), dockerSock: strings.TrimSpace(v.GetString("docker_socket")), network: strings.TrimSpace(v.GetString("browser_network")), token: strings.TrimSpace(v.GetString("gateway_token")), logLevel: level, } if cfg.listenAddr == "" { return serviceConfig{}, errors.New("LISTEN_ADDR must not be empty") } if err := validateListenAddr(cfg.listenAddr); err != nil { return serviceConfig{}, err } if cfg.dockerSock == "" { return serviceConfig{}, errors.New("DOCKER_SOCKET must not be empty") } if len(cfg.token) < 16 { return serviceConfig{}, errors.New("GATEWAY_TOKEN must be at least 16 characters") } if err := validateBrowserNetwork(cfg.network); err != nil { return serviceConfig{}, err } return cfg, nil } func validateListenAddr(addr string) error { _, port, err := net.SplitHostPort(addr) if err != nil { return errors.New("LISTEN_ADDR must be a host:port address") } number, err := strconv.Atoi(port) if err != nil || number < 1 || number > 65535 { return errors.New("LISTEN_ADDR port must be 1..65535") } return nil } func run(command *cobra.Command, cfg serviceConfig) error { transport := &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", cfg.dockerSock) }, } defer transport.CloseIdleConnections() docker := dockerClient{ baseURL: "http://docker/v1.43", client: &http.Client{Transport: transport, Timeout: 30 * time.Second}, slow: &http.Client{Transport: transport}, } logrus.WithFields(logrus.Fields{ "service": "docker-gateway", "listen_addr": cfg.listenAddr, "network": cfg.network, }).Info("service starting") return newGateway(docker, cfg.network, cfg.token).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) } func newGateway(client dockerClient, network, token string) *fiber.App { self, _ := os.Hostname() return newGatewayWithSelf(client, network, token, self) } func newGatewayWithSelf(client dockerClient, network, token, self string) *fiber.App { return newGatewayWithBrowser(client, network, token, self, cdpBrowser{}) } func newGatewayWithBrowser(client dockerClient, network, token, self string, browser restrictedBrowser) *fiber.App { api := gateway{docker: client, network: network, self: self, token: token, proxies: newMemoryProxyRegistry(), locks: &dockerAliasReservations{docker: client, self: self}, browser: browser} app := fiber.New(fiber.Config{ AppName: "CreatorHub Docker gateway", BodyLimit: 1 << 20, ReadTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, ErrorHandler: func(c fiber.Ctx, err error) error { status := http.StatusInternalServerError var fiberError *fiber.Error if errors.As(err, &fiberError) && fiberError != nil { status = fiberError.Code } return writeError(c, status, err) }, }) app.Get("/healthz", func(c fiber.Ctx) error { c.Status(fiber.StatusNoContent) return nil }) app.Use("/v1", api.authorize) app.Get("/v1/browsers", api.list) app.Post("/v1/browsers", api.create) app.Post("/v1/browsers/:id/proxy", api.restoreProxy) app.Post("/v1/browsers/:id/douyin/cookies", api.setDouyinCookies) app.Post("/v1/browsers/:id/douyin/get", api.getDouyin) app.Post("/v1/browsers/:id/:action", api.changeState) app.Delete("/v1/browsers/:id", api.remove) return app } func (api gateway) authorize(c fiber.Ctx) error { expected := "Bearer " + api.token if subtle.ConstantTimeCompare([]byte(c.Get(fiber.HeaderAuthorization)), []byte(expected)) != 1 { return writeError(c, http.StatusUnauthorized, errUnauthorized) } return c.Next() } func (api gateway) list(c fiber.Ctx) error { 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 { return writeError(c, http.StatusBadGateway, err) } defer result.Body.Close() if result.StatusCode != http.StatusOK { return forwardDockerError(c, result) } 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 { return writeError(c, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err)) } browsers := make([]browser, 0, len(containers)) for _, container := range containers { alias := container.Labels[idLabel] if !runtimeIDPattern.MatchString(alias) { continue } name := container.Labels[nameLabel] if name == "" { name = alias } bindingVersion, _ := strconv.ParseInt(container.Labels[bindingVersionLabel], 10, 64) proxyPort, _ := strconv.Atoi(container.Labels[proxyPortLabel]) direct := container.Labels[networkExitLabel] == "" browsers = append(browsers, browser{ ID: container.ID, Alias: alias, Name: name, State: container.State, Status: container.Status, Endpoint: "http://" + namePrefix + alias + ":9222", BindingVersion: bindingVersion, NetworkExitID: container.Labels[networkExitLabel], NetworkID: container.Labels[networkIDLabel], ProxyReady: direct || api.proxies.ready(alias, proxyPort, container.ID, container.Labels[networkIDLabel]), }) } return writeJSON(c, http.StatusOK, browsers) } func (api gateway) create(c fiber.Ctx) error { var input createRequest decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil { return writeError(c, http.StatusBadRequest, errors.New("body must contain only alias, name, image, cmd, volume, binding_version, network_exit_id, network_exit and stopped")) } if err := validateCreate(input); err != nil { return writeError(c, http.StatusBadRequest, err) } if err := api.docker.pullIfMissing(c.Context(), input.Image); err != nil { return writeError(c, http.StatusBadGateway, err) } if _, _, err := api.managedContainer(input.Alias); err == nil { return writeError(c, http.StatusConflict, errors.New("browser alias is already in use")) } else if !errors.Is(err, os.ErrNotExist) { return writeError(c, statusFor(err), err) } _, release, err := api.locks.acquire(input.Alias) if err != nil { return writeError(c, statusFor(err), err) } defer release() if _, _, err := api.managedContainer(input.Alias); err == nil { return writeError(c, http.StatusConflict, errors.New("browser alias is already in use")) } else if !errors.Is(err, os.ErrNotExist) { return writeError(c, statusFor(err), err) } direct := input.NetworkExitID == "" network, proxyServer, undoProxy := "none", "", func() {} var networkGeneration tenantNetworkGeneration keepNetwork := input.Stopped if !input.Stopped { var err error var bindHost string networkGeneration, bindHost, err = api.docker.ensureTenantNetwork(api.network, input.Alias, api.self, input.BindingVersion, "", "", false) if networkGeneration.ID != "" { defer func() { if keepNetwork { return } var cleanupErr error if networkGeneration.Created { cleanupErr = api.removeTenantNetwork(input.Alias, input.BindingVersion, "", networkGeneration, nil, "", false) } else if networkGeneration.ConnectedSelf { _, cleanupErr = api.disconnectTenantNetworkMember(input.Alias, input.BindingVersion, "", networkGeneration, networkGeneration.SelfMember, nil, "", false) } if cleanupErr != nil { logrus.WithError(cleanupErr).WithField("alias", input.Alias).Error("rollback isolated browser network") } }() } if err != nil { return writeNetworkError(c, http.StatusBadGateway, errors.New("configure isolated browser network"), networkGeneration.ID) } network = networkGeneration.ID if !direct { proxyServer, undoProxy, err = api.proxies.configure(input.Alias, input.BindingVersion, bindHost, 0, input.NetworkExit, networkGeneration.ID) if err != nil { return writeNetworkError(c, statusFor(err), errors.Join(errors.New("configure in-memory proxy"), err), networkGeneration.ID) } } } keepProxy := false defer func() { if !keepProxy { undoProxy() } }() pidsLimit := int64(512) cmd := append([]string{}, input.Cmd...) if !input.Stopped && !direct { cmd = append(cmd[:len(cmd)-1], "--proxy-server="+proxyServer, "--disable-non-proxied-udp", cmd[len(cmd)-1]) } payload := map[string]any{ "Image": input.Image, "User": browserUser, "Entrypoint": []string{browserEntrypoint}, "Cmd": cmd, "Env": []string{"REMOTE_DEBUGGING_PORT=9222"}, "Labels": map[string]string{ managedLabel: "true", idLabel: input.Alias, nameLabel: input.Name, bindingVersionLabel: strconv.FormatInt(input.BindingVersion, 10), networkExitLabel: input.NetworkExitID, networkIDLabel: networkGeneration.ID, proxyPortLabel: strconv.Itoa(proxyPort(proxyServer)), }, "ExposedPorts": map[string]any{"9222/tcp": map[string]any{}}, "HostConfig": map[string]any{ "NetworkMode": 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": input.Volume, "Target": "/data", }}, }, } result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Alias), payload) var created struct { ID string `json:"Id"` } status, createErr := http.StatusBadGateway, err if result != nil { if result.StatusCode == http.StatusConflict { status = http.StatusConflict } if result.StatusCode == http.StatusCreated { createErr = json.NewDecoder(result.Body).Decode(&created) } else { createErr = errors.New("Docker container creation failed") } result.Body.Close() } if createErr != nil || created.ID == "" { containerID, labels, inspectErr := api.managedContainer(input.Alias) if inspectErr == nil && labels[bindingVersionLabel] == strconv.FormatInt(input.BindingVersion, 10) && labels[networkIDLabel] == networkGeneration.ID { created.ID = containerID } else { if createErr == nil { createErr = errors.New("Docker returned an invalid container id") } return writeNetworkError(c, status, createErr, networkGeneration.ID) } } if !input.Stopped { if !direct { if !api.proxies.bind(input.Alias, input.BindingVersion, proxyServer, created.ID, networkGeneration.ID) { cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent) if cleanupErr != nil { return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("proxy generation changed and container cleanup failed: %w", cleanupErr), networkGeneration.ID) } return writeNetworkError(c, http.StatusConflict, errGenerationConflict, networkGeneration.ID) } undoProxy = func() { api.proxies.remove(input.Alias, input.BindingVersion, created.ID) } } if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil { cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(created.ID)+"?force=1&v=0", nil, http.StatusNoContent) if cleanupErr != nil { return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr), networkGeneration.ID) } return writeNetworkError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err), networkGeneration.ID) } } keepProxy, keepNetwork = !input.Stopped && !direct, true return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias, "network_id": networkGeneration.ID}) } func validateCreate(input createRequest) error { if !runtimeIDPattern.MatchString(input.Alias) { return errors.New("alias must match [a-z0-9][a-z0-9-]{0,31}") } if input.Name == "" || utf8.RuneCountInString(input.Name) > 64 || hasControlRunes(input.Name) { return errors.New("name must be 1..64 visible characters") } if !imageRefPattern.MatchString(input.Image) { return errors.New("image must be a valid image reference") } if !volumePattern.MatchString(input.Volume) { return errors.New("volume must be a valid volume name") } direct := input.NetworkExitID == "" && input.NetworkExit == (gatewayProxyExit{}) if input.BindingVersion < 1 || (input.Stopped && !direct) || (!input.Stopped && !direct && !exitIDPattern.MatchString(input.NetworkExitID)) || (input.NetworkExitID == "") != (input.NetworkExit == (gatewayProxyExit{})) { return errors.New("binding_version and network_exit_id must identify the current binding") } if len(input.Cmd) == 0 || len(input.Cmd) > 64 || input.Cmd[len(input.Cmd)-1] != "about:blank" { return errors.New("cmd must contain 1..64 arguments") } total := 0 for _, arg := range input.Cmd { if arg == "" || hasControlRunes(arg) { return errors.New("cmd arguments must be non-empty visible strings") } if strings.HasPrefix(arg, "--proxy-server") || arg == "--disable-non-proxied-udp" { return errors.New("proxy arguments are platform-controlled") } total += len(arg) } if total > 4096 { return errors.New("cmd arguments exceed 4096 characters") } if input.Stopped || direct { return nil } proxy := input.NetworkExit if (proxy.Protocol != "http" && proxy.Protocol != "https" && proxy.Protocol != "socks4" && proxy.Protocol != "socks5") || proxy.Host == "" || len(proxy.Host) > 253 || strings.ContainsAny(proxy.Host, "@/[]?# \t\r\n") || proxy.Port < 1 || proxy.Port > 65535 || (proxy.Username == "" && proxy.Password != "") || len(proxy.Username) > 255 || len(proxy.Password) > 255 || hasControlRunes(proxy.Username) || hasControlRunes(proxy.Password) { return errors.New("network_exit must contain a valid proxy endpoint and optional credentials") } return nil } func proxyPort(proxyServer string) int { parsed, _ := url.Parse(proxyServer) port, _ := strconv.Atoi(parsed.Port()) return port } func hasControlRunes(value string) bool { for _, r := range value { if r < 0x20 || r == 0x7f { return true } } return false } func (api gateway) changeState(c fiber.Ctx) error { id := c.Params("id") action := c.Params("action") var input generationRequest switch action { case "start": var err error input, err = decodeGeneration(c) if err != nil { return writeError(c, http.StatusBadRequest, err) } if input.NetworkID == "" { return writeError(c, http.StatusBadRequest, errors.New("network_id must identify the expected network generation")) } _, exists, err := api.requireGeneration(id, input) if err != nil { return writeError(c, statusFor(err), err) } if !exists { return writeError(c, http.StatusNotFound, os.ErrNotExist) } case "stop": var err error input, err = decodeGeneration(c) if err != nil { return writeError(c, http.StatusBadRequest, err) } _, exists, err := api.requireGeneration(id, input) if err != nil { return writeError(c, statusFor(err), err) } if !exists { return writeError(c, http.StatusNotFound, os.ErrNotExist) } default: return writeError(c, http.StatusNotFound, errors.New("unknown action")) } _, release, err := api.locks.acquire(id) if err != nil { return writeError(c, statusFor(err), err) } defer release() var path string if action == "start" { containerID, exists, err := api.requireGeneration(id, input) if err != nil { return writeError(c, statusFor(err), err) } if !exists { return writeError(c, http.StatusNotFound, os.ErrNotExist) } path = "/containers/" + url.PathEscape(containerID) + "/start" } else { containerID, exists, err := api.requireGeneration(id, input) if err != nil { return writeError(c, statusFor(err), err) } if !exists { return writeError(c, http.StatusNotFound, os.ErrNotExist) } path = "/containers/" + url.PathEscape(containerID) + "/stop?t=10" } if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil { return writeError(c, http.StatusBadGateway, err) } c.Status(http.StatusNoContent) return nil } func (api gateway) remove(c fiber.Ctx) error { id := c.Params("id") input, err := decodeGeneration(c) if err != nil { return writeError(c, http.StatusBadRequest, err) } containerID, exists, err := api.requireGeneration(id, input) if err != nil { return writeError(c, statusFor(err), err) } _, release, err := api.locks.acquire(id) if err != nil { return writeError(c, statusFor(err), err) } defer release() currentID, currentExists, err := api.requireGeneration(id, input) if err != nil { return writeError(c, statusFor(err), err) } if currentExists != exists || currentID != containerID { return writeError(c, http.StatusConflict, errGenerationConflict) } expectedNetworkID := input.NetworkID if currentExists { _, labels, inspectErr := api.managedContainer(id) if inspectErr != nil { return writeError(c, statusFor(inspectErr), inspectErr) } containerNetworkID := labels[networkIDLabel] if containerNetworkID != input.NetworkID { return writeError(c, http.StatusConflict, errGenerationConflict) } } else if expectedNetworkID == "" { return writeError(c, http.StatusConflict, errGenerationConflict) } var networkGeneration tenantNetworkGeneration networkExists := false if expectedNetworkID != "" { networkGeneration, _, networkExists, err = api.docker.inspectTenantNetwork(api.network, id, input.BindingVersion, input.RuntimeID, api.self, expectedNetworkID, false) if err != nil { return writeError(c, statusFor(err), err) } } if networkExists { err = api.removeTenantNetwork(id, input.BindingVersion, input.RuntimeID, networkGeneration, &input, containerID, exists) } else if expectedNetworkID != "" { return writeError(c, http.StatusConflict, errGenerationConflict) } if err != nil { if errors.Is(err, errGenerationConflict) { return writeError(c, http.StatusConflict, err) } return c.Status(http.StatusAccepted).JSON(map[string]string{ "status": "runtime_cleanup_pending", }) } if !api.proxies.remove(id, input.BindingVersion, input.RuntimeID, input.NetworkID) { return writeError(c, http.StatusConflict, errGenerationConflict) } if exists { path := "/containers/" + url.PathEscape(containerID) + "?force=1&v=0" if err := api.requireRuntimeState(id, input, containerID, true); err != nil { return writeError(c, http.StatusConflict, err) } if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil { currentID, currentExists, generationErr := api.requireGeneration(id, input) if errors.Is(err, os.ErrNotExist) && generationErr == nil && !currentExists { return c.SendStatus(http.StatusNoContent) } if generationErr != nil || currentID != containerID { err = errGenerationConflict } if errors.Is(err, errGenerationConflict) { return writeError(c, http.StatusConflict, err) } return writeError(c, http.StatusBadGateway, err) } } c.Status(http.StatusNoContent) return nil } func (api gateway) restoreProxy(c fiber.Ctx) error { input := proxyRestoreRequest{} decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil || input.BindingVersion < 1 || !exitIDPattern.MatchString(input.RuntimeID) || !exitIDPattern.MatchString(input.NetworkID) || !exitIDPattern.MatchString(input.NetworkExitID) || validateCreate(createRequest{Alias: c.Params("id"), Name: "x", Image: "x", Cmd: []string{"about:blank"}, Volume: "x", BindingVersion: input.BindingVersion, NetworkExitID: input.NetworkExitID, NetworkExit: input.NetworkExit}) != nil { return writeError(c, http.StatusBadRequest, errors.New("invalid proxy recovery request")) } removeStaleProxy := func() { api.proxies.remove(c.Params("id"), input.BindingVersion, input.RuntimeID, input.NetworkID) } runtimeID, labels, err := api.requireProxyGeneration(c.Params("id"), input) if err != nil { removeStaleProxy() return writeError(c, statusFor(err), err) } port, _ := strconv.Atoi(labels[proxyPortLabel]) if port < 1 { removeStaleProxy() return writeError(c, http.StatusConflict, errors.New("container binding does not match recovery request")) } _, release, err := api.locks.acquire(c.Params("id")) if err != nil { return writeError(c, statusFor(err), err) } defer release() if runtimeID, labels, err = api.requireProxyGeneration(c.Params("id"), input); err != nil { removeStaleProxy() return writeError(c, statusFor(err), err) } port, _ = strconv.Atoi(labels[proxyPortLabel]) if port < 1 { removeStaleProxy() return writeError(c, http.StatusConflict, errors.New("container binding does not match recovery request")) } networkGeneration, bindHost, err := api.docker.ensureTenantNetwork(api.network, c.Params("id"), api.self, input.BindingVersion, input.RuntimeID, labels[networkIDLabel], true) keepNetwork := false if networkGeneration.ID != "" { defer func() { if keepNetwork { return } var cleanupErr error if networkGeneration.Created { cleanupErr = api.removeTenantNetwork(c.Params("id"), input.BindingVersion, input.RuntimeID, networkGeneration, nil, "", false) } else { if networkGeneration.ConnectedRuntime { _, cleanupErr = api.disconnectTenantNetworkMember(c.Params("id"), input.BindingVersion, input.RuntimeID, networkGeneration, input.RuntimeID, nil, "", false) } if cleanupErr == nil && networkGeneration.ConnectedSelf { _, cleanupErr = api.disconnectTenantNetworkMember(c.Params("id"), input.BindingVersion, input.RuntimeID, networkGeneration, networkGeneration.SelfMember, nil, "", false) } } if cleanupErr != nil { logrus.WithError(cleanupErr).WithField("alias", c.Params("id")).Error("rollback restored browser network") } }() } if err != nil { removeStaleProxy() return writeError(c, http.StatusBadGateway, errors.New("restore isolated browser network")) } if err = api.requireProxyNetworkGeneration(c.Params("id"), input, networkGeneration, bindHost); err != nil { removeStaleProxy() return writeError(c, statusFor(err), err) } proxyServer, undoProxy, err := api.proxies.configure(c.Params("id"), input.BindingVersion, bindHost, port, input.NetworkExit, input.NetworkID) if err != nil { removeStaleProxy() return writeError(c, statusFor(err), errors.Join(errors.New("restore in-memory proxy"), err)) } if err = api.requireProxyNetworkGeneration(c.Params("id"), input, networkGeneration, bindHost); err != nil { undoProxy() return writeError(c, statusFor(err), err) } if !api.proxies.bind(c.Params("id"), input.BindingVersion, proxyServer, runtimeID, input.NetworkID) { undoProxy() return writeError(c, http.StatusConflict, errGenerationConflict) } if err = api.requireProxyNetworkGeneration(c.Params("id"), input, networkGeneration, bindHost); err != nil { undoProxy() return writeError(c, statusFor(err), err) } keepNetwork = true return c.SendStatus(http.StatusNoContent) } func (api gateway) requireProxyNetworkGeneration(alias string, input proxyRestoreRequest, expected tenantNetworkGeneration, bindHost string) error { _, labels, err := api.requireProxyGeneration(alias, input) if err != nil { return err } if networkID := labels[networkIDLabel]; networkID != "" && networkID != expected.ID { return errGenerationConflict } current, addresses, exists, err := api.docker.inspectTenantNetwork(api.network, alias, input.BindingVersion, input.RuntimeID, api.self, expected.ID, false) host, _, _ := net.ParseCIDR(addresses[current.SelfMember]) if err != nil || !exists || !sameTenantNetworkMembers(current, expected) || host == nil || host.String() != bindHost { return errGenerationConflict } return nil } func sameTenantNetworkMembers(current, expected tenantNetworkGeneration) bool { if current.ID != expected.ID || current.Name != expected.Name || current.RuntimeAttached != expected.RuntimeAttached || current.SelfMember != expected.SelfMember || len(current.GatewayMembers) != len(expected.GatewayMembers) { return false } members := make(map[string]struct{}, len(current.GatewayMembers)) for _, member := range current.GatewayMembers { members[member] = struct{}{} } for _, member := range expected.GatewayMembers { if _, ok := members[member]; !ok { return false } } return true } func (api gateway) requireProxyGeneration(alias string, input proxyRestoreRequest) (string, map[string]string, error) { runtimeID, labels, err := api.managedContainer(alias) if err != nil { return "", nil, err } version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64) if runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkExitLabel] != input.NetworkExitID || labels[networkIDLabel] != input.NetworkID { return "", nil, errGenerationConflict } return runtimeID, labels, nil } func decodeGeneration(c fiber.Ctx) (generationRequest, error) { var input generationRequest decoder := json.NewDecoder(bytes.NewReader(c.Body())) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil || input.BindingVersion < 1 || (input.RuntimeID != "" && !exitIDPattern.MatchString(input.RuntimeID)) || (input.NetworkID != "" && !exitIDPattern.MatchString(input.NetworkID)) { return generationRequest{}, errors.New("binding_version, runtime_id and network_id must identify the expected generation") } return input, nil } func (api gateway) requireGeneration(id string, input generationRequest) (string, bool, error) { runtimeID, labels, err := api.managedContainer(id) if errors.Is(err, os.ErrNotExist) { return "", false, nil } if err != nil { return "", false, err } version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64) if input.RuntimeID == "" || runtimeID != input.RuntimeID || version != input.BindingVersion || labels[networkIDLabel] != input.NetworkID { return "", false, errGenerationConflict } return runtimeID, true, nil } func (api gateway) managedContainer(id string) (string, map[string]string, error) { runtimeID, labels, _, err := api.managedContainerState(id) return runtimeID, labels, err } func (api gateway) managedContainerState(id string) (string, map[string]string, map[string]string, error) { if !runtimeIDPattern.MatchString(id) { return "", nil, nil, errInvalidRuntimeID } result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil) if err != nil { return "", nil, nil, err } defer result.Body.Close() if result.StatusCode == http.StatusNotFound { return "", nil, nil, os.ErrNotExist } if result.StatusCode != http.StatusOK { return "", nil, nil, fmt.Errorf("Docker inspect returned %s", result.Status) } var inspected struct { ID string `json:"Id"` Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` NetworkSettings struct { Networks map[string]struct { NetworkID string `json:"NetworkID"` } `json:"Networks"` } `json:"NetworkSettings"` } if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil { return "", nil, nil, fmt.Errorf("decode Docker inspect: %w", err) } if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id { return "", nil, nil, errUnmanagedContainer } networks := make(map[string]string, len(inspected.NetworkSettings.Networks)) for name, network := range inspected.NetworkSettings.Networks { networks[name] = network.NetworkID } return inspected.ID, inspected.Config.Labels, networks, nil } // pullIfMissing 在镜像不在本地时从远端仓库拉取;镜像缺失属于可恢复错误,调用方可直接重试。 func (docker dockerClient) pullIfMissing(ctx context.Context, ref string) error { inspect, err := docker.request(http.MethodGet, "/images/"+url.PathEscape(ref)+"/json", nil) if err != nil { return fmt.Errorf("inspect image %s: %w", ref, err) } _, _ = io.Copy(io.Discard, inspect.Body) _ = inspect.Body.Close() switch inspect.StatusCode { case http.StatusOK: return nil case http.StatusNotFound: // 本地无此镜像,继续拉取 default: return fmt.Errorf("inspect image %s returned %s", ref, inspect.Status) } pullCtx, cancel := context.WithTimeout(ctx, pullTimeout) defer cancel() query := url.Values{"fromImage": {ref}} if !strings.Contains(ref, "@") { if repository, tag := splitImageRef(ref); tag != "" { query = url.Values{"fromImage": {repository}, "tag": {tag}} } } request, err := http.NewRequestWithContext(pullCtx, http.MethodPost, docker.baseURL+"/images/create?"+query.Encode(), nil) if err != nil { return fmt.Errorf("build image pull request: %w", err) } response, err := docker.slow.Do(request) if err != nil { return fmt.Errorf("pull image %s: %w", ref, err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) return fmt.Errorf("pull image %s returned %s: %s", ref, response.Status, strings.TrimSpace(string(message))) } _, _ = io.Copy(io.Discard, response.Body) return nil } func splitImageRef(ref string) (repository, tag string) { if at := strings.Index(ref, "@"); at >= 0 { return ref[:at], ref[at+1:] } if colon := strings.LastIndex(ref, ":"); colon > strings.LastIndex(ref, "/") { return ref[:colon], ref[colon+1:] } return ref, "" } 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 } } if response.StatusCode == http.StatusNotFound { return os.ErrNotExist } message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message))) } func (locks *dockerAliasReservations) acquire(alias string) (string, func(), error) { inspected, err := locks.docker.request(http.MethodGet, "/containers/"+url.PathEscape(locks.self)+"/json", nil) if err != nil { return "", nil, err } var gatewayContainer struct { Image string `json:"Image"` Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` } if inspected.StatusCode != http.StatusOK || json.NewDecoder(inspected.Body).Decode(&gatewayContainer) != nil || gatewayContainer.Image == "" || gatewayContainer.Config.Labels[gatewayMemberLabel] != "true" { inspected.Body.Close() return "", nil, errors.New("inspect trusted gateway for alias reservation") } inspected.Body.Close() generationBytes := make([]byte, 16) if _, err := rand.Read(generationBytes); err != nil { return "", nil, errors.New("create alias reservation generation") } generation := hex.EncodeToString(generationBytes) response, err := locks.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(reservationPrefix+alias), map[string]any{ "Image": gatewayContainer.Image, "Labels": map[string]string{reservationLabel: "true", idLabel: alias, reservationGenLabel: generation}, "HostConfig": map[string]any{"NetworkMode": "none"}, }) if err != nil { return "", nil, locks.reconcile(alias, generation, "", fmt.Errorf("create alias reservation result unknown: %w", err)) } defer response.Body.Close() if response.StatusCode == http.StatusConflict { return "", nil, errGenerationConflict } if response.StatusCode != http.StatusCreated { return "", nil, locks.reconcile(alias, generation, "", fmt.Errorf("create alias reservation returned %s", response.Status)) } var created struct { ID string `json:"Id"` } if json.NewDecoder(response.Body).Decode(&created) != nil || created.ID == "" { return "", nil, locks.reconcile(alias, generation, "", errors.New("Docker returned an invalid alias reservation id")) } actualID, inspectErr := locks.inspect(alias, generation) if inspectErr != nil { return "", nil, fmt.Errorf("Docker returned an unverified alias reservation id; generation %s requires manual reconcile: %w", generation, inspectErr) } if actualID != created.ID { return "", nil, fmt.Errorf("Docker returned an alias reservation id that conflicts with generation %s; manual reconcile required", generation) } return created.ID, func() { if err := locks.remove(alias, generation, created.ID); err != nil { logrus.WithError(err).WithFields(logrus.Fields{"alias": alias, "reservation_id": created.ID, "reservation_generation": generation}).Error("alias reservation cleanup pending; manual reconcile required") } }, nil } func (locks *dockerAliasReservations) reconcile(alias, generation, expectedID string, cause error) error { actualID, inspectErr := locks.inspect(alias, generation) if inspectErr != nil { return fmt.Errorf("%w; reservation generation %s requires manual reconcile: %v", cause, generation, inspectErr) } if expectedID != "" && actualID != expectedID { return fmt.Errorf("%w; reservation generation %s conflicts with immutable id", cause, generation) } if cleanupErr := locks.remove(alias, generation, actualID); cleanupErr != nil { return fmt.Errorf("%w; reservation %s generation %s cleanup pending: %v", cause, actualID, generation, cleanupErr) } return fmt.Errorf("%w; reservation %s was removed", cause, actualID) } func (locks *dockerAliasReservations) remove(alias, generation, expectedID string) error { var deleteErr error for attempt := 0; attempt < 2; attempt++ { actualID, inspectErr := locks.inspect(alias, generation) if errors.Is(inspectErr, os.ErrNotExist) { return nil } if inspectErr != nil { return inspectErr } if actualID != expectedID { return errors.New("reservation generation conflicts with immutable id") } deleteErr = locks.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(expectedID)+"?force=1&v=0", nil, http.StatusNoContent) if deleteErr == nil { return nil } if errors.Is(deleteErr, os.ErrNotExist) { _, confirmErr := locks.inspect(alias, generation) if errors.Is(confirmErr, os.ErrNotExist) { return nil } return errors.Join(deleteErr, confirmErr) } } return deleteErr } func (locks *dockerAliasReservations) inspect(alias, generation string) (string, error) { response, err := locks.docker.request(http.MethodGet, "/containers/"+url.PathEscape(reservationPrefix+alias)+"/json", nil) if err != nil { return "", err } defer response.Body.Close() if response.StatusCode == http.StatusNotFound { return "", os.ErrNotExist } var reservation struct { ID string `json:"Id"` Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` } if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&reservation) != nil || reservation.ID == "" || reservation.Config.Labels[reservationLabel] != "true" || reservation.Config.Labels[idLabel] != alias || reservation.Config.Labels[reservationGenLabel] != generation { return "", errors.New("reservation name does not identify the expected alias generation") } return reservation.ID, nil } func tenantNetworkName(base, alias string) (string, error) { name := base + "-" + alias if !networkNamePattern.MatchString(name) { return "", errors.New("isolated browser network name is invalid") } return name, nil } func sameContainerReference(id, name, reference string) bool { if reference == "" { return false } return id == reference || name == reference || strings.HasPrefix(id, reference) || strings.HasPrefix(reference, id) } func (docker dockerClient) trustedGatewayMember(id string) bool { response, err := docker.request(http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil) if err != nil { return false } defer response.Body.Close() var container struct { ID string `json:"Id"` Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` } return response.StatusCode == http.StatusOK && json.NewDecoder(response.Body).Decode(&container) == nil && container.ID != "" && container.Config.Labels[gatewayMemberLabel] == "true" } func (docker dockerClient) inspectTenantNetwork(base, alias string, bindingVersion int64, runtimeID, self, expectedID string, allowUnversioned bool) (tenantNetworkGeneration, map[string]string, bool, error) { name, err := tenantNetworkName(base, alias) if err != nil { return tenantNetworkGeneration{}, nil, false, err } generation := tenantNetworkGeneration{ID: expectedID, Name: name} reference := name if expectedID != "" { reference = expectedID } response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(reference), nil) if err != nil { return generation, nil, false, err } defer response.Body.Close() if response.StatusCode == http.StatusNotFound { return generation, nil, false, nil } var network dockerTenantNetwork if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&network) != nil || network.ID == "" || network.Name != name || network.Driver != "bridge" || network.Internal || network.Attachable || network.Ingress || network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole || network.Labels[idLabel] != alias { return generation, nil, false, errors.New("refusing to operate on an unowned browser network") } if expectedID != "" && network.ID != expectedID { return generation, nil, false, errGenerationConflict } networkVersion := network.Labels[bindingVersionLabel] if networkVersion != strconv.FormatInt(bindingVersion, 10) && !(allowUnversioned && networkVersion == "") { return generation, nil, false, errGenerationConflict } generation.ID = network.ID addresses := make(map[string]string, len(network.Containers)) for id, member := range network.Containers { addresses[id] = member.IPv4Address if id == runtimeID { generation.RuntimeAttached = true continue } if !docker.trustedGatewayMember(id) { return generation, nil, false, errGenerationConflict } generation.GatewayMembers = append(generation.GatewayMembers, id) if sameContainerReference(id, member.Name, self) { generation.SelfMember = id } } return generation, addresses, true, nil } func (docker dockerClient) ensureTenantNetwork(base, alias, self string, bindingVersion int64, runtimeID, expectedID string, allowUnversioned bool) (tenantNetworkGeneration, string, error) { if self == "" { return tenantNetworkGeneration{}, "", errors.New("isolated browser network identity is invalid") } generation, addresses, exists, err := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, expectedID, allowUnversioned) if err != nil { return generation, "", err } if !exists { if expectedID != "" { return generation, "", errGenerationConflict } response, createErr := docker.request(http.MethodPost, "/networks/create", map[string]any{ "Name": generation.Name, "CheckDuplicate": true, "Driver": "bridge", "Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias, bindingVersionLabel: strconv.FormatInt(bindingVersion, 10)}, }) if createErr != nil { return generation, "", fmt.Errorf("create isolated browser network result unknown; immutable generation requires manual reconcile: %w", createErr) } var created struct { ID string `json:"Id"` } if response.StatusCode != http.StatusCreated || json.NewDecoder(response.Body).Decode(&created) != nil || created.ID == "" { response.Body.Close() return generation, "", errors.New("create isolated browser network result has no immutable id; manual reconcile required") } response.Body.Close() generation.ID, generation.Created = created.ID, true observed, currentAddresses, observedExists, inspectErr := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, false) generation = preserveTenantNetworkGeneration(generation, observed) addresses, err = currentAddresses, inspectErr if err != nil || !observedExists { if err == nil { err = errGenerationConflict } return generation, "", err } } if runtimeID != "" && !generation.RuntimeAttached { generation.ConnectedRuntime = true if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(generation.ID)+"/connect", map[string]any{ "Container": runtimeID, }, http.StatusOK); err != nil { observed, _, _, _ := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, allowUnversioned) generation = preserveTenantNetworkGeneration(generation, observed) return generation, "", err } generation.RuntimeAttached = true } if generation.SelfMember == "" { generation.ConnectedSelf = true if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(generation.ID)+"/connect", map[string]any{ "Container": self, "EndpointConfig": map[string]any{"Aliases": []string{browserProxyHost}}, }, http.StatusOK); err != nil { observed, _, _, _ := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, allowUnversioned) generation = preserveTenantNetworkGeneration(generation, observed) return generation, "", err } } observed, currentAddresses, observedExists, inspectErr := docker.inspectTenantNetwork(base, alias, bindingVersion, runtimeID, self, generation.ID, allowUnversioned) generation = preserveTenantNetworkGeneration(generation, observed) addresses, err = currentAddresses, inspectErr if err != nil || !observedExists || generation.SelfMember == "" { if err == nil && !observedExists { err = errGenerationConflict } return generation, "", errors.Join(err, errors.New("Docker did not connect the gateway to the isolated network")) } host, _, _ := net.ParseCIDR(addresses[generation.SelfMember]) if host == nil { return generation, "", errors.New("Docker did not assign the gateway an isolated network address") } return generation, host.String(), nil } func preserveTenantNetworkGeneration(known, observed tenantNetworkGeneration) tenantNetworkGeneration { if observed.ID == "" { observed.ID = known.ID } if observed.Name == "" { observed.Name = known.Name } observed.Created = observed.Created || known.Created observed.RuntimeAttached = observed.RuntimeAttached || known.RuntimeAttached observed.ConnectedRuntime = observed.ConnectedRuntime || known.ConnectedRuntime observed.ConnectedSelf = observed.ConnectedSelf || known.ConnectedSelf if observed.SelfMember == "" { observed.SelfMember = known.SelfMember } for _, member := range known.GatewayMembers { if !memberPresent(observed, member, "") { observed.GatewayMembers = append(observed.GatewayMembers, member) } } return observed } func (api gateway) requireRuntimeState(alias string, input generationRequest, expectedID string, expectedExists bool) error { runtimeID, exists, err := api.requireGeneration(alias, input) if err != nil || exists != expectedExists || runtimeID != expectedID { return errGenerationConflict } return nil } func memberPresent(generation tenantNetworkGeneration, id, runtimeID string) bool { if generation.RuntimeAttached && runtimeID != "" && sameContainerReference(id, "", runtimeID) { return true } for _, member := range generation.GatewayMembers { if sameContainerReference(member, "", id) { return true } } return false } func generationWithoutMember(generation tenantNetworkGeneration, member, runtimeID string) tenantNetworkGeneration { if generation.RuntimeAttached && runtimeID != "" && sameContainerReference(member, "", runtimeID) { generation.RuntimeAttached = false } if sameContainerReference(member, "", generation.SelfMember) { generation.SelfMember = "" } members := generation.GatewayMembers[:0:0] for _, current := range generation.GatewayMembers { if !sameContainerReference(current, "", member) { members = append(members, current) } } generation.GatewayMembers = members return generation } func (api gateway) disconnectTenantNetworkMember(alias string, bindingVersion int64, runtimeID string, generation tenantNetworkGeneration, member string, input *generationRequest, expectedRuntime string, expectedExists bool) (tenantNetworkGeneration, error) { if input != nil { if err := api.requireRuntimeState(alias, *input, expectedRuntime, expectedExists); err != nil { return generation, err } } current, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false) if err != nil || !exists { return current, err } if !sameTenantNetworkMembers(current, generation) { return current, errGenerationConflict } if !memberPresent(current, member, runtimeID) { return current, nil } err = api.docker.expect(http.MethodPost, "/networks/"+url.PathEscape(generation.ID)+"/disconnect", map[string]any{ "Container": member, "Force": true, }, http.StatusOK) current, _, exists, fenceErr := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false) if fenceErr != nil || !exists { return current, errGenerationConflict } if sameTenantNetworkMembers(current, generationWithoutMember(generation, member, runtimeID)) { return current, nil } if err != nil { if sameTenantNetworkMembers(current, generation) { return current, err } return current, errGenerationConflict } if !sameTenantNetworkMembers(current, generation) { return current, errGenerationConflict } return current, errors.New("Docker retained an isolated network member after disconnect") } func (api gateway) deleteTenantNetwork(alias string, bindingVersion int64, runtimeID string, generation tenantNetworkGeneration, input *generationRequest, expectedRuntime string, expectedExists bool) error { if input != nil { if err := api.requireRuntimeState(alias, *input, expectedRuntime, expectedExists); err != nil { return err } } current, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false) if err != nil { return err } if !exists { return nil } if current.RuntimeAttached || len(current.GatewayMembers) != 0 { return errGenerationConflict } err = api.docker.expect(http.MethodDelete, "/networks/"+url.PathEscape(generation.ID), nil, http.StatusNoContent) _, _, exists, fenceErr := api.docker.inspectTenantNetwork(api.network, alias, bindingVersion, runtimeID, api.self, generation.ID, false) if fenceErr != nil { return errGenerationConflict } if !exists { return nil } if err != nil { return err } return errors.New("Docker retained the isolated browser network after delete") } func (api gateway) removeTenantNetwork(alias string, bindingVersion int64, runtimeID string, generation tenantNetworkGeneration, input *generationRequest, expectedRuntime string, expectedExists bool) error { if generation.RuntimeAttached || generation.ConnectedRuntime { var err error generation, err = api.disconnectTenantNetworkMember(alias, bindingVersion, runtimeID, generation, runtimeID, input, expectedRuntime, expectedExists) if err != nil { return fmt.Errorf("disconnect browser from isolated network: %w", err) } } for len(generation.GatewayMembers) > 0 { gatewayID := generation.GatewayMembers[0] var err error generation, err = api.disconnectTenantNetworkMember(alias, bindingVersion, runtimeID, generation, gatewayID, input, expectedRuntime, expectedExists) if err != nil { return fmt.Errorf("disconnect trusted gateway from isolated network: %w", err) } } if err := api.deleteTenantNetwork(alias, bindingVersion, runtimeID, generation, input, expectedRuntime, expectedExists); err != nil { return fmt.Errorf("remove isolated browser network: %w", err) } return nil } func validateBrowserNetwork(name string) error { if !networkNamePattern.MatchString(name) || len(name) > 31 { return errors.New("BROWSER_NETWORK must be a valid network prefix of at most 31 characters") } if name == controlNetworkName { return errors.New("BROWSER_NETWORK must not reuse the control network") } return nil } func statusFor(err error) int { switch { case errors.Is(err, errInvalidRuntimeID): return http.StatusBadRequest case errors.Is(err, os.ErrNotExist): return http.StatusNotFound case errors.Is(err, errUnmanagedContainer): return http.StatusForbidden case errors.Is(err, errGenerationConflict): return http.StatusConflict default: return http.StatusBadGateway } } func forwardDockerError(c fiber.Ctx, result *http.Response) error { message, _ := io.ReadAll(io.LimitReader(result.Body, 4096)) status := http.StatusBadGateway if result.StatusCode == http.StatusConflict { status = http.StatusConflict } return writeError(c, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message)))) } func writeError(c fiber.Ctx, status int, err error) error { return writeJSON(c, status, map[string]string{"error": err.Error()}) } func writeNetworkError(c fiber.Ctx, status int, err error, networkID string) error { return writeJSON(c, status, map[string]string{"error": err.Error(), "network_id": networkID}) } func writeJSON(c fiber.Ctx, status int, value any) error { return c.Status(status).JSON(value) }