package api import ( "fmt" "net" "os" "path" "path/filepath" "slices" "sort" "strconv" "strings" "werkdock/internal/engine" "werkdock/internal/store" ) // A quirk is the per-image knowledge werkdock needs because two of // docker's promises are not werkdock's to keep (RFC 0005): // // - There is no network namespace, so a published port cannot be // mapped. The service must be told to bind the port the client is // to be given, which every serious image allows through an // environment variable. // - There is one uid. An image whose entrypoint starts as root and // drops privileges itself (the official postgres image and its // gosu dance) has to be started as the target user right away; // those entrypoints detect a non-root uid and skip the dance. // // This is a deliberate deviation from docker, documented in RFC 0005 // and in the CHANGELOG. An image that is not listed runs as the image // says and keeps its port, which is correct as long as one instance of // it runs at a time. type quirk struct { // User is what to run as when neither the request nor the image // names a user. User string // PortEnv maps a container port to the environment variable that // moves the service to another port. PortEnv map[int]string // PortArg maps a container port to a command-line flag that does // the same, for a server that reads no environment variable for it. // It applies only when the command is still the image's own, so // werkdock never appends a flag to a command somebody else chose. PortArg map[int]portArg // PortFile maps a container port to the config file the image // ships that names it, for a server that reads its port from // nowhere else. Werkdock copies that file with the line rewritten // and binds the copy over the original; the image's own file is // never touched. It applies only while the command still names // the file (mosquitto -c /mosquitto/config/mosquitto.conf) and // the client has not bound something of its own over it. PortFile map[int]portFile // MaxHostPort caps what werkdock may choose for this image, for a // server that derives a second port from the one it is given. // RabbitMQ's Erlang distribution port is the node port plus 20000 // and must still be a port, so its node port cannot exceed 45535 — // and the kernel's ephemeral range, where freePort draws from, // starts above that more often than not. Zero means no cap. MaxHostPort int } // portArg is "append this flag and the port, but only to this program". // A Flag ending in "=" is joined to the number (--port=3306); otherwise // the two are separate words (--port 6379), which is what redis-server // wants. type portArg struct { Program string Flag string } // words renders the flag for a chosen port. func (a portArg) words(port int) []string { if strings.HasSuffix(a.Flag, "=") { return []string{a.Flag + strconv.Itoa(port)} } return []string{a.Flag, strconv.Itoa(port)} } // portFile is "this line in this file names the port". Line is a // format with one %d; the exact line for the image's own port is // replaced by the same line for the chosen one, nothing else in the // file changes. type portFile struct { Path string Line string } // lines renders the line as the image ships it and as it is to be. func (f portFile) lines(from, to int) (string, string) { return fmt.Sprintf(f.Line, from), fmt.Sprintf(f.Line, to) } var quirks = map[string]quirk{ "postgres": {User: "postgres", PortEnv: map[int]string{5432: "PGPORT"}}, // MariaDB's entrypoint reads the port out of the server's own // configuration (mysql_get_config 'port' "$@"), not out of the // environment: with MARIADB_TCP_PORT set the server stayed on 3306. // The flag reaches both the entrypoint's reading of it and the // server itself, because the entrypoint passes "$@" on. "mariadb": {User: "mysql", PortArg: map[int]portArg{3306: {Program: "mariadbd", Flag: "--port="}}}, // MySQL is MariaDB's relative and behaves the same way. 33060 is the // X protocol, which the image also opens: it moves only when a // client asks for it, so two instances at once share it and the // second one's X plugin fails while its SQL port works. "mysql": {User: "mysql", PortArg: map[int]portArg{ 3306: {Program: "mysqld", Flag: "--port="}, 33060: {Program: "mysqld", Flag: "--mysqlx-port="}, }}, // NATS takes --port, and its config file does not get in the way: // a flag on the command line wins over the port in the config the // image ships. Measured. "nats": {PortArg: map[int]portArg{4222: {Program: "nats-server", Flag: "--port"}}}, // RabbitMQ does read its environment variable, but the port it is // given is not the only one it derives from it: the Erlang // distribution port is that plus 20000. A node port above 45535 // therefore fails to boot with "Invalid Erlang distribution TCP // port", which is what MaxHostPort is for. It also chowns its data // directory as root, like postgres and redis. "rabbitmq": {User: "rabbitmq", PortEnv: map[int]string{5672: "RABBITMQ_NODE_PORT"}, MaxHostPort: 45535}, // Mosquitto needs its own user for the same reason — it drops // privileges itself and cannot setgroups in a user namespace. Its // port is the hard case: the config the image ships declares its // listeners explicitly, an explicit listener turns off the default // one that -p configures (the manual says so, and measured: with // -p it went on listening on 1883), it reads no environment // variable, and the config file knows no variables either. What // is left is the config file itself: the shipped one, copied with // "listener 1883" rewritten, bound over the original. The second // listener, the dashboard's HTTP API on 9883, moves the same way. // Measured with 2.1.2: both listeners came up on the rewritten // ports and an anonymous client was accepted, as the shipped // config allows. "eclipse-mosquitto": {User: "mosquitto", PortFile: map[int]portFile{ 1883: {Path: "/mosquitto/config/mosquitto.conf", Line: "listener %d"}, 9883: {Path: "/mosquitto/config/mosquitto.conf", Line: "listener %d"}, }}, // Redis reads no environment variable for its port — measured, not // assumed: with REDIS_PORT set the server stayed on 6379 and // werkdock reported a port nothing listened on. redis-server takes // --port instead. The user is needed for the same reason postgres // needs one: the entrypoint chowns its data directory as root, which // an unprivileged user namespace cannot do. "redis": {User: "redis", PortArg: map[int]portArg{6379: {Program: "redis-server", Flag: "--port"}}}, } // moves reports how werkdock would move this port, if it can: by // environment variable, by command-line flag, by rewriting a config // file, or not at all. A flag counts only when the command is the // image's own program, because appending to a command the client // chose would be a guess; a file counts only when the command still // names it and the client has not bound one of their own over it // (supplied lists the destinations of the request's binds), because // then the rewritten copy would not be what the server reads. func (q quirk) moves(container int, command, supplied []string) (env string, arg *portArg, file *portFile) { if name, ok := q.PortEnv[container]; ok { return name, nil, nil } if a, ok := q.PortArg[container]; ok { for _, word := range command { if path.Base(word) == a.Program { return "", &a, nil } } } if f, ok := q.PortFile[container]; ok { if slices.Contains(command, f.Path) && !slices.Contains(supplied, f.Path) { return "", nil, &f } } return "", nil, nil } // quirkFor looks the table up by the image's bare repository name, so // postgres, library/postgres and docker.io/library/postgres:16-alpine // are the same image. func quirkFor(reference string) quirk { ref, err := store.ParseReference(reference) if err != nil { return quirk{} } repo := ref.Repository if i := strings.LastIndex(repo, "/"); i >= 0 { repo = repo[i+1:] } return quirks[repo] } // port is one published port and where it actually listens. type port struct { Container int Host int Protocol string } // parsePortKey reads docker's "5432/tcp". func parsePortKey(key string) (int, string, error) { spec, proto, found := strings.Cut(key, "/") if !found { proto = "tcp" } n, err := strconv.Atoi(spec) if err != nil || n <= 0 || n > 65535 { return 0, "", fmt.Errorf("invalid port %q", key) } return n, proto, nil } // freePort asks the kernel for an unused port. There is a window // between this and the service binding it; werkdock accepts it, as // every port picker without a network namespace must. // // A max caps the answer. The kernel's own choice comes from the // ephemeral range, which on Linux starts at 32768 and so lies above // some images' limit more often than not; below a cap werkdock has to // look for itself, upwards from the end of the registered range. func freePort(max int) (int, error) { if max <= 0 { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, err } defer ln.Close() return ln.Addr().(*net.TCPAddr).Port, nil } for port := 20000; port <= max; port++ { ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)) if err != nil { continue } ln.Close() return port, nil } return 0, fmt.Errorf("no free port below %d", max) } // moved is everything the sandbox has to be given so that the service // binds the ports werkdock reported: environment, command-line words, // and config files to rewrite. type moved struct { Env map[string]string Args []string Files []fileEdit } // fileEdit is one port's line in one file, from the image's number to // the chosen one. type fileEdit struct { File portFile From, To int } // publishPorts decides where a container's ports really listen. // For a port the image can be moved (the quirk table), a requested // host port is honoured and an empty one is chosen free; the service // is then told through its environment variable, a flag, or its config // file. For every other port the answer is the identity: the sandbox // shares the host's network, so the port the service binds is the // port the client reaches. command is what the sandbox will run and // supplied the destinations of the request's own binds; both decide // whether a flag or a file can be applied at all. func publishPorts(exposed map[string]struct{}, bindings map[string][]portBinding, q quirk, command, supplied []string) ([]port, moved, error) { keys := map[string]bool{} for k := range exposed { keys[k] = true } for k := range bindings { keys[k] = true } m := moved{Env: map[string]string{}} var ports []port for _, key := range sortedPortKeys(keys) { container, proto, err := parsePortKey(key) if err != nil { return nil, moved{}, err } requested := 0 if list := bindings[key]; len(list) > 0 && list[0].HostPort != "" { if requested, err = strconv.Atoi(list[0].HostPort); err != nil { return nil, moved{}, fmt.Errorf("invalid host port %q for %s", list[0].HostPort, key) } } name, arg, file := q.moves(container, command, supplied) movable := name != "" || arg != nil || file != nil host := requested switch { case movable && requested == 0: if host, err = freePort(q.MaxHostPort); err != nil { return nil, moved{}, err } case movable && q.MaxHostPort != 0 && requested > q.MaxHostPort: return nil, moved{}, fmt.Errorf( "cannot publish %s on host port %d: this image derives a second port from the one it is "+ "given and cannot be sent above %d", key, requested, q.MaxHostPort) case !movable && requested != 0 && requested != container: return nil, moved{}, fmt.Errorf( "cannot publish %s on host port %d: werkdock shares the host's network and cannot map ports; "+ "the service would have to bind %d itself, and werkdock does not know how to tell this image to", key, requested, requested) case !movable: host = container } ports = append(ports, port{Container: container, Host: host, Protocol: proto}) switch { case name != "": m.Env[name] = strconv.Itoa(host) case arg != nil: m.Args = append(m.Args, arg.words(host)...) case file != nil: m.Files = append(m.Files, fileEdit{File: *file, From: container, To: host}) } } sortPorts(ports) return ports, m, nil } // A renderedFile is a config file with its port lines rewritten, ready // to be written below the instance directory and bound over the // image's copy. type renderedFile struct { Dest string Content []byte } // renderPortFiles reads each file the edits name out of the image and // rewrites the lines. Every line must be there: a quirk is measured // knowledge about one image, and an image whose file has changed is // refused with the reason rather than started on a port werkdock only // claimed. Files come back in path order, one per path however many // ports it carries. func renderPortFiles(rootfs string, edits []fileEdit) ([]renderedFile, error) { byPath := map[string][]fileEdit{} for _, e := range edits { byPath[e.File.Path] = append(byPath[e.File.Path], e) } paths := make([]string, 0, len(byPath)) for p := range byPath { paths = append(paths, p) } var out []renderedFile for _, p := range sortSlice(paths) { raw, err := os.ReadFile(filepath.Join(rootfs, filepath.FromSlash(p))) if err != nil { return nil, fmt.Errorf("the port of this image lives in %s, which the image does not have: %w", p, err) } lines := strings.Split(string(raw), "\n") for _, e := range byPath[p] { from, to := e.File.lines(e.From, e.To) found := false for i, line := range lines { if strings.TrimSpace(line) == from { lines[i], found = to, true } } if !found { return nil, fmt.Errorf("cannot move port %d: werkdock expected %q in the image's %s and this image's copy has no such line", e.From, from, p) } } out = append(out, renderedFile{Dest: p, Content: []byte(strings.Join(lines, "\n"))}) } return out, nil } // placePortFiles writes the rendered files below the instance // directory and returns the read-only binds that put each over the // image's original. func placePortFiles(dir string, files []renderedFile) ([]engine.Mount, error) { var mounts []engine.Mount for _, f := range files { name := strings.ReplaceAll(strings.TrimPrefix(f.Dest, "/"), "/", "-") source := filepath.Join(dir, "quirk", name) if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { return nil, err } if err := os.WriteFile(source, f.Content, 0o644); err != nil { return nil, err } mounts = append(mounts, engine.Mount{Mode: engine.MountRoBind, Source: source, Dest: f.Dest}) } return mounts, nil } func sortSlice(keys []string) []string { sort.Strings(keys); return keys } // sortedPortKeys makes the chosen ports reproducible when more than one // is published: map iteration order must not decide which gets which. func sortedPortKeys(keys map[string]bool) []string { out := make([]string, 0, len(keys)) for k := range keys { out = append(out, k) } return sortSlice(out) } func sortPorts(ports []port) { for i := 1; i < len(ports); i++ { for j := i; j > 0 && ports[j].Container < ports[j-1].Container; j-- { ports[j], ports[j-1] = ports[j-1], ports[j] } } }