Files
creator-hub/cmd/docker-gateway/main.go
T

840 lines
29 KiB
Go

package main
import (
"bytes"
"context"
"crypto/subtle"
"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"
networkRoleLabel = "io.creatorhub.network-role"
browserNetworkRole = "browser"
controlNetworkName = "creatorhub_control"
namePrefix = "creatorhub-browser-"
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")
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 gateway struct {
docker dockerClient
network string
self string
token string
proxies *memoryProxyRegistry
}
// 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 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"`
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 {
api := gateway{docker: client, network: network, self: self, token: token, proxies: newMemoryProxyRegistry()}
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/: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])
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],
ProxyReady: api.proxies.ready(alias, proxyPort),
})
}
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)
}
network, proxyServer, undoProxy := "none", "", func() {}
if !input.Stopped {
var err error
var bindHost string
network, bindHost, err = api.docker.ensureTenantNetwork(api.network, input.Alias, api.self)
if err != nil {
return writeError(c, http.StatusBadGateway, errors.New("configure isolated browser network"))
}
proxyServer, undoProxy, err = api.proxies.configure(input.Alias, bindHost, 0, input.NetworkExit)
if err != nil {
return writeError(c, http.StatusBadGateway, errors.New("configure in-memory proxy"))
}
}
keepProxy := false
defer func() {
if !keepProxy {
undoProxy()
}
}()
pidsLimit := int64(512)
cmd := append([]string{}, input.Cmd...)
if !input.Stopped {
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,
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)
if err != nil {
return writeError(c, http.StatusBadGateway, err)
}
defer result.Body.Close()
if result.StatusCode != http.StatusCreated {
status := http.StatusBadGateway
if result.StatusCode == http.StatusConflict {
status = http.StatusConflict
}
return writeError(c, status, errors.New("Docker container creation failed"))
}
var created struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" {
cleanupErr := api.docker.expect(http.MethodDelete, "/containers/"+url.PathEscape(namePrefix+input.Alias)+"?force=1&v=0", nil, http.StatusNoContent, http.StatusNotFound)
if cleanupErr != nil {
return writeError(c, http.StatusBadGateway, fmt.Errorf("Docker returned an invalid container id and cleanup failed: %w", cleanupErr))
}
return writeError(c, http.StatusBadGateway, errors.New("Docker returned an invalid container id; container was removed while preserving its Profile volume"))
}
if !input.Stopped {
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 writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and cleanup failed: %w; cleanup: %v", err, cleanupErr))
}
return writeError(c, http.StatusBadGateway, fmt.Errorf("container did not start and was removed while preserving its Profile volume: %w", err))
}
}
keepProxy = !input.Stopped
return writeJSON(c, http.StatusCreated, map[string]string{"id": created.ID, "alias": input.Alias})
}
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")
}
if input.BindingVersion < 1 || (!input.Stopped && !exitIDPattern.MatchString(input.NetworkExitID)) ||
(input.Stopped && (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 {
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")
if err := api.requireManaged(id); err != nil {
return writeError(c, statusFor(err), err)
}
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:
return writeError(c, http.StatusNotFound, errors.New("unknown action"))
}
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")
err := api.requireManaged(id)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return writeError(c, statusFor(err), err)
}
if err == nil {
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent, http.StatusNotFound); err != nil {
return writeError(c, http.StatusBadGateway, err)
}
}
api.proxies.remove(id)
if err := api.docker.removeTenantNetwork(api.network, id, api.self); err != nil {
return c.Status(http.StatusAccepted).JSON(map[string]string{
"status": "container_removed_network_cleanup_pending",
})
}
c.Status(http.StatusNoContent)
return nil
}
func (api gateway) restoreProxy(c fiber.Ctx) error {
input := struct {
BindingVersion int64 `json:"binding_version"`
NetworkExitID string `json:"network_exit_id"`
NetworkExit gatewayProxyExit `json:"network_exit"`
}{}
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil || input.BindingVersion < 1 || !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"))
}
labels, err := api.managedLabels(c.Params("id"))
if err != nil {
return writeError(c, statusFor(err), err)
}
version, _ := strconv.ParseInt(labels[bindingVersionLabel], 10, 64)
port, _ := strconv.Atoi(labels[proxyPortLabel])
if version != input.BindingVersion || labels[networkExitLabel] != input.NetworkExitID || port < 1 {
return writeError(c, http.StatusConflict, errors.New("container binding does not match recovery request"))
}
_, bindHost, err := api.docker.ensureTenantNetwork(api.network, c.Params("id"), api.self)
if err != nil {
return writeError(c, http.StatusBadGateway, errors.New("restore isolated browser network"))
}
if _, _, err := api.proxies.configure(c.Params("id"), bindHost, port, input.NetworkExit); err != nil {
return writeError(c, http.StatusBadGateway, errors.New("restore in-memory proxy"))
}
return c.SendStatus(http.StatusNoContent)
}
func (api gateway) requireManaged(id string) error {
_, err := api.managedLabels(id)
return err
}
func (api gateway) managedLabels(id string) (map[string]string, error) {
if !runtimeIDPattern.MatchString(id) {
return nil, errInvalidRuntimeID
}
result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
if err != nil {
return nil, err
}
defer result.Body.Close()
if result.StatusCode == http.StatusNotFound {
return nil, os.ErrNotExist
}
if result.StatusCode != http.StatusOK {
return nil, 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 nil, fmt.Errorf("decode Docker inspect: %w", err)
}
if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
return nil, errUnmanagedContainer
}
return inspected.Config.Labels, 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
}
}
message, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message)))
}
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 (docker dockerClient) ensureTenantNetwork(base, alias, self string) (string, string, error) {
name, err := tenantNetworkName(base, alias)
if err != nil || self == "" {
return "", "", errors.New("isolated browser network identity is invalid")
}
inspect := func() (map[string]struct {
Name string `json:"Name"`
IPv4Address string `json:"IPv4Address"`
}, error) {
response, requestErr := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
if requestErr != nil {
return nil, requestErr
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return nil, os.ErrNotExist
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("inspect isolated browser network returned %s", response.Status)
}
var network struct {
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"`
}
if err := json.NewDecoder(response.Body).Decode(&network); err != nil {
return nil, err
}
if 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 nil, errors.New("isolated browser network is not owned by this runtime")
}
return network.Containers, nil
}
containers, err := inspect()
if errors.Is(err, os.ErrNotExist) {
if err := docker.expect(http.MethodPost, "/networks/create", map[string]any{
"Name": name, "CheckDuplicate": true, "Driver": "bridge",
"Labels": map[string]string{managedLabel: "true", networkRoleLabel: browserNetworkRole, idLabel: alias},
}, http.StatusCreated); err != nil {
return "", "", err
}
containers = map[string]struct {
Name string `json:"Name"`
IPv4Address string `json:"IPv4Address"`
}{}
} else if err != nil {
return "", "", err
}
findIP := func() string {
for id, container := range containers {
if id == self || strings.HasPrefix(id, self) || strings.HasPrefix(self, id) || container.Name == self {
host, _, _ := net.ParseCIDR(container.IPv4Address)
if host != nil {
return host.String()
}
}
}
return ""
}
if bindHost := findIP(); bindHost != "" {
return name, bindHost, nil
}
if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(name)+"/connect", map[string]any{
"Container": self, "EndpointConfig": map[string]any{"Aliases": []string{browserProxyHost}},
}, http.StatusOK); err != nil {
return "", "", err
}
containers, err = inspect()
if err != nil {
return "", "", err
}
if bindHost := findIP(); bindHost != "" {
return name, bindHost, nil
}
return "", "", errors.New("Docker did not assign the gateway an isolated network address")
}
func (docker dockerClient) removeTenantNetwork(base, alias, self string) error {
name, err := tenantNetworkName(base, alias)
if err != nil {
return err
}
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return nil
}
var network struct {
Name string `json:"Name"`
Labels map[string]string `json:"Labels"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&network) != nil || network.Name != name ||
network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole || network.Labels[idLabel] != alias {
return errors.New("refusing to remove an unowned browser network")
}
if err := docker.expect(http.MethodPost, "/networks/"+url.PathEscape(name)+"/disconnect", map[string]any{
"Container": self, "Force": true,
}, http.StatusOK, http.StatusNotFound); err != nil {
return fmt.Errorf("disconnect isolated browser network: %w", err)
}
if err := docker.expect(http.MethodDelete, "/networks/"+url.PathEscape(name), nil, http.StatusNoContent, http.StatusNotFound); 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
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 writeJSON(c fiber.Ctx, status int, value any) error {
return c.Status(status).JSON(value)
}