1501 lines
54 KiB
Go
1501 lines
54 KiB
Go
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
|
|
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 {
|
|
api := gateway{docker: client, network: network, self: self, token: token, proxies: newMemoryProxyRegistry(),
|
|
locks: &dockerAliasReservations{docker: client, self: self}}
|
|
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],
|
|
NetworkID: container.Labels[networkIDLabel],
|
|
ProxyReady: 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)
|
|
}
|
|
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
|
|
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 {
|
|
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 !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, 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")
|
|
}
|
|
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")
|
|
var input generationRequest
|
|
switch action {
|
|
case "start":
|
|
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)
|
|
}
|
|
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); 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); 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); 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) error {
|
|
_, labels, err := api.requireProxyGeneration(alias, input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if networkID := labels[networkIDLabel]; networkID != "" && networkID != expected.ID {
|
|
return errGenerationConflict
|
|
}
|
|
current, _, exists, err := api.docker.inspectTenantNetwork(api.network, alias, input.BindingVersion,
|
|
input.RuntimeID, api.self, expected.ID, false)
|
|
if err != nil || !exists || !sameTenantNetworkMembers(current, expected) {
|
|
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) {
|
|
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 {
|
|
ID string `json:"Id"`
|
|
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.ID, 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
|
|
}
|
|
}
|
|
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)
|
|
}
|