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

401 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
)
const (
managedLabel = "io.creatorhub.managed"
idLabel = "io.creatorhub.runtime-id"
networkRoleLabel = "io.creatorhub.network-role"
browserNetworkRole = "browser"
controlNetworkName = "creatorhub_control"
bridgeICCOption = "com.docker.network.bridge.enable_icc"
namePrefix = "creatorhub-browser-"
)
var runtimeIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
var networkNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
type config struct {
image string
network string
}
type dockerClient struct {
baseURL string
client *http.Client
}
type gateway struct {
docker dockerClient
config config
}
type createRequest struct {
Name string `json:"name"`
Seed int64 `json:"seed"`
}
type browser struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Status string `json:"status"`
Profile string `json:"profile"`
Endpoint string `json:"endpoint"`
}
func main() {
image := os.Getenv("BROWSER_IMAGE")
if image == "" || strings.HasSuffix(image, ":latest") || !strings.Contains(image, ":") {
log.Fatal("BROWSER_IMAGE must be a pinned image tag")
}
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", env("DOCKER_SOCKET", "/var/run/docker.sock"))
},
}
docker := dockerClient{
baseURL: "http://docker/v1.43",
client: &http.Client{Transport: transport, Timeout: 30 * time.Second},
}
network := env("BROWSER_NETWORK", "creatorhub_browser")
if err := docker.ensureInternalNetwork(network); err != nil {
log.Fatal(err)
}
handler := newGateway(docker, config{image: image, network: network})
server := &http.Server{
Addr: env("LISTEN_ADDR", ":8081"),
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("restricted Docker gateway listening on %s", server.Addr)
log.Fatal(server.ListenAndServe())
}
func newGateway(client dockerClient, cfg config) http.Handler {
api := gateway{docker: client, config: cfg}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("GET /v1/browsers", api.list)
mux.HandleFunc("POST /v1/browsers", api.create)
mux.HandleFunc("POST /v1/browsers/{id}/{action}", api.changeState)
mux.HandleFunc("DELETE /v1/browsers/{id}", api.remove)
return mux
}
func (api gateway) list(response http.ResponseWriter, _ *http.Request) {
filters, _ := json.Marshal(map[string][]string{"label": {managedLabel + "=true"}})
result, err := api.docker.request(http.MethodGet, "/containers/json?all=1&filters="+url.QueryEscape(string(filters)), nil)
if err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
defer result.Body.Close()
if result.StatusCode != http.StatusOK {
forwardDockerError(response, result)
return
}
var containers []struct {
ID string `json:"Id"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
if err := json.NewDecoder(result.Body).Decode(&containers); err != nil {
writeError(response, http.StatusBadGateway, fmt.Errorf("decode Docker response: %w", err))
return
}
browsers := make([]browser, 0, len(containers))
for _, container := range containers {
id := container.Labels[idLabel]
if !runtimeIDPattern.MatchString(id) {
continue
}
browsers = append(browsers, browser{
ID: container.ID,
Name: id,
State: container.State,
Status: container.Status,
Profile: "creatorhub-profile-" + id,
Endpoint: "http://" + namePrefix + id + ":9222",
})
}
writeJSON(response, http.StatusOK, browsers)
}
func (api gateway) create(response http.ResponseWriter, request *http.Request) {
var input createRequest
decoder := json.NewDecoder(http.MaxBytesReader(response, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeError(response, http.StatusBadRequest, errors.New("body must contain only name and numeric seed"))
return
}
if !runtimeIDPattern.MatchString(input.Name) || input.Seed < 1 || input.Seed > 2147483647 {
writeError(response, http.StatusBadRequest, errors.New("name must match [a-z0-9][a-z0-9-]{0,31} and seed must be 1..2147483647"))
return
}
pidsLimit := int64(512)
payload := map[string]any{
"Image": api.config.image,
"Cmd": []string{"--fingerprint=" + strconv.FormatInt(input.Seed, 10), "about:blank"},
"Env": []string{"REMOTE_DEBUGGING_PORT=9222"},
"Labels": map[string]string{
managedLabel: "true",
idLabel: input.Name,
},
"ExposedPorts": map[string]any{"9222/tcp": map[string]any{}},
"HostConfig": map[string]any{
"NetworkMode": api.config.network,
"ReadonlyRootfs": true,
"CapDrop": []string{"ALL"},
"SecurityOpt": []string{"no-new-privileges"},
"PidsLimit": &pidsLimit,
"Memory": int64(1 << 30),
"NanoCpus": int64(2_000_000_000),
"Tmpfs": map[string]string{
"/tmp": "rw,nosuid,nodev,noexec,mode=1777,size=256m",
"/tmp/.X11-unix": "rw,nosuid,nodev,noexec,mode=1777,size=1m",
"/dev/shm": "rw,nosuid,nodev,noexec,size=256m",
"/home/ubuntu": "rw,nosuid,nodev,noexec,uid=1000,gid=1000,mode=700,size=64m",
},
"Mounts": []map[string]any{{
"Type": "volume",
"Source": "creatorhub-profile-" + input.Name,
"Target": "/data",
}},
},
}
result, err := api.docker.request(http.MethodPost, "/containers/create?name="+url.QueryEscape(namePrefix+input.Name), payload)
if err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
defer result.Body.Close()
if result.StatusCode != http.StatusCreated {
forwardDockerError(response, result)
return
}
var created struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(result.Body).Decode(&created); err != nil || created.ID == "" {
writeError(response, http.StatusBadGateway, errors.New("Docker returned an invalid container id"))
return
}
if err := api.docker.expect(http.MethodPost, "/containers/"+url.PathEscape(created.ID)+"/start", nil, http.StatusNoContent, http.StatusNotModified); err != nil {
writeError(response, http.StatusBadGateway, fmt.Errorf("container created but did not start: %w", err))
return
}
writeJSON(response, http.StatusCreated, map[string]string{"id": created.ID, "name": input.Name})
}
func (api gateway) changeState(response http.ResponseWriter, request *http.Request) {
id := request.PathValue("id")
action := request.PathValue("action")
if err := api.requireManaged(id); err != nil {
writeError(response, statusFor(err), err)
return
}
var path string
switch action {
case "start":
path = "/containers/" + url.PathEscape(namePrefix+id) + "/start"
case "stop":
path = "/containers/" + url.PathEscape(namePrefix+id) + "/stop?t=10"
default:
writeError(response, http.StatusNotFound, errors.New("unknown action"))
return
}
if err := api.docker.expect(http.MethodPost, path, nil, http.StatusNoContent, http.StatusNotModified); err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
response.WriteHeader(http.StatusNoContent)
}
func (api gateway) remove(response http.ResponseWriter, request *http.Request) {
id := request.PathValue("id")
if err := api.requireManaged(id); err != nil {
writeError(response, statusFor(err), err)
return
}
path := "/containers/" + url.PathEscape(namePrefix+id) + "?force=1&v=0"
if err := api.docker.expect(http.MethodDelete, path, nil, http.StatusNoContent); err != nil {
writeError(response, http.StatusBadGateway, err)
return
}
response.WriteHeader(http.StatusNoContent)
}
func (api gateway) requireManaged(id string) error {
if !runtimeIDPattern.MatchString(id) {
return errors.New("invalid runtime id")
}
result, err := api.docker.request(http.MethodGet, "/containers/"+url.PathEscape(namePrefix+id)+"/json", nil)
if err != nil {
return err
}
defer result.Body.Close()
if result.StatusCode == http.StatusNotFound {
return os.ErrNotExist
}
if result.StatusCode != http.StatusOK {
return fmt.Errorf("Docker inspect returned %s", result.Status)
}
var inspected struct {
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
}
if err := json.NewDecoder(result.Body).Decode(&inspected); err != nil {
return fmt.Errorf("decode Docker inspect: %w", err)
}
if inspected.Config.Labels[managedLabel] != "true" || inspected.Config.Labels[idLabel] != id {
return errors.New("refusing to operate on a container not owned by CreatorHub")
}
return nil
}
func (docker dockerClient) request(method, path string, payload any) (*http.Response, error) {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequest(method, docker.baseURL+path, body)
if err != nil {
return nil, err
}
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
return docker.client.Do(request)
}
func (docker dockerClient) expect(method, path string, payload any, allowed ...int) error {
response, err := docker.request(method, path, payload)
if err != nil {
return err
}
defer response.Body.Close()
for _, status := range allowed {
if response.StatusCode == status {
return nil
}
}
message, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("Docker returned %s: %s", response.Status, strings.TrimSpace(string(message)))
}
func (docker dockerClient) ensureInternalNetwork(name string) error {
if !networkNamePattern.MatchString(name) {
return errors.New("BROWSER_NETWORK is invalid")
}
if name == controlNetworkName {
return errors.New("BROWSER_NETWORK must not reuse the control network")
}
response, err := docker.request(http.MethodGet, "/networks/"+url.PathEscape(name), nil)
if err != nil {
return fmt.Errorf("inspect browser network: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusOK {
var network struct {
Name string `json:"Name"`
Driver string `json:"Driver"`
Internal bool `json:"Internal"`
Attachable bool `json:"Attachable"`
Ingress bool `json:"Ingress"`
Options map[string]string `json:"Options"`
Labels map[string]string `json:"Labels"`
}
if err := json.NewDecoder(response.Body).Decode(&network); err != nil {
return fmt.Errorf("decode browser network: %w", err)
}
if network.Name != name || network.Driver != "bridge" || !network.Internal || network.Attachable || network.Ingress ||
network.Labels[managedLabel] != "true" || network.Labels[networkRoleLabel] != browserNetworkRole ||
len(network.Options) != 1 || network.Options[bridgeICCOption] != "false" {
return fmt.Errorf("browser network %q is not an isolated CreatorHub bridge", name)
}
return nil
}
if response.StatusCode != http.StatusNotFound {
return fmt.Errorf("inspect browser network returned %s", response.Status)
}
return docker.expect(http.MethodPost, "/networks/create", map[string]any{
"Name": name,
"CheckDuplicate": true,
"Driver": "bridge",
"Internal": true,
"Options": map[string]string{bridgeICCOption: "false"},
"Labels": map[string]string{
managedLabel: "true",
networkRoleLabel: browserNetworkRole,
},
}, http.StatusCreated)
}
func statusFor(err error) int {
if errors.Is(err, os.ErrNotExist) {
return http.StatusNotFound
}
if strings.Contains(err.Error(), "refusing") {
return http.StatusForbidden
}
return http.StatusBadRequest
}
func forwardDockerError(response http.ResponseWriter, result *http.Response) {
message, _ := io.ReadAll(io.LimitReader(result.Body, 4096))
status := http.StatusBadGateway
if result.StatusCode == http.StatusConflict {
status = http.StatusConflict
}
writeError(response, status, fmt.Errorf("Docker returned %s: %s", result.Status, strings.TrimSpace(string(message))))
}
func writeError(response http.ResponseWriter, status int, err error) {
writeJSON(response, status, map[string]string{"error": err.Error()})
}
func writeJSON(response http.ResponseWriter, status int, value any) {
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(status)
_ = json.NewEncoder(response).Encode(value)
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}