package api // The Docker tab: what is running, grouped the way it was deployed (compose stacks), with // start / stop / restart, logs and inspect — plus images, volumes and networks to look at. // It drives the docker CLI (or podman), as the user first and as root if that is refused. // What can be removed here is what nothing uses: an untagged image, a volume no container // holds, a network nothing is attached to. Compose stays the one place a stack is defined. import ( "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "os/exec" "regexp" "sort" "strconv" "strings" "time" "hamlab/internal/priv" ) type dockerContainer struct { ID string `json:"id"` Name string `json:"name"` Image string `json:"image"` State string `json:"state"` // running | exited | paused | restarting | created | dead Status string `json:"status"` // "Up 3 hours (healthy)" Health string `json:"health"` // healthy | unhealthy | starting | none Ports string `json:"ports"` Stack string `json:"stack"` // compose project, "" when started by hand Service string `json:"service"` // compose service Created string `json:"created"` Mem float64 `json:"mem"` // bytes, from the container's cgroup; 0 when unknown } func dockerTool() string { for _, t := range []string{"docker", "podman"} { if _, err := exec.LookPath(t); err == nil { return t } } return "" } // dockerRun runs the tool as this user, and again as root if the socket said no. // DockerTool is whichever of docker or podman this machine has, for callers outside the // package — the scheduled updates the CLI runs. func DockerTool() string { return dockerTool() } func dockerRun(ctx context.Context, args ...string) ([]byte, error) { tool := dockerTool() if tool == "" { return nil, errors.New("neither docker nor podman is installed here") } out, err := exec.CommandContext(ctx, tool, args...).CombinedOutput() if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") { full := priv.Wrap(append([]string{tool}, args...)...) out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput() } if err != nil { msg := strings.TrimSpace(string(out)) if i := strings.LastIndex(msg, "\n"); i >= 0 { msg = msg[i+1:] } if msg == "" { msg = err.Error() } return out, errors.New(msg) } return out, nil } // dockerWhy picks the line worth showing out of a failed command. Some subcommands — // `network rm` among them — print the real reason first and then an aggregated "exit // status 1", which is what dockerRun's last-line rule would otherwise hand back. func dockerWhy(out []byte, err error) string { for _, l := range strings.Split(string(out), "\n") { if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") { return l } } return err.Error() } func jsonLines(out []byte, each func(map[string]any)) { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { var m map[string]any if json.Unmarshal([]byte(line), &m) == nil { each(m) } } } func dstr(m map[string]any, k string) string { s, _ := m[k].(string); return s } func labelOf(labels, key string) string { for _, kv := range strings.Split(labels, ",") { if v, ok := strings.CutPrefix(kv, key+"="); ok { return v } } return "" } func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) { if dockerTool() == "" { jsonOK(w, 200, map[string]any{"available": false, "message": "Docker isn't installed on this machine."}) return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}") if err != nil { jsonOK(w, 200, map[string]any{"available": false, "message": err.Error()}) return } containers := []dockerContainer{} jsonLines(out, func(m map[string]any) { c := dockerContainer{ID: dstr(m, "ID"), Name: strings.Split(dstr(m, "Names"), ",")[0], Image: dstr(m, "Image"), State: dstr(m, "State"), Status: dstr(m, "Status"), Ports: dstr(m, "Ports"), Created: dstr(m, "CreatedAt"), Health: "none", Stack: labelOf(dstr(m, "Labels"), "com.docker.compose.project"), Service: labelOf(dstr(m, "Labels"), "com.docker.compose.service")} for _, h := range []string{"unhealthy", "healthy", "starting"} { if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "health: "+h) { c.Health = h break } } for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} { if b, err := os.ReadFile(dir + "/memory.current"); err == nil { c.Mem, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64) break } } containers = append(containers, c) }) sort.Slice(containers, func(i, j int) bool { if containers[i].Stack != containers[j].Stack { return containers[i].Stack < containers[j].Stack } return containers[i].Name < containers[j].Name }) list := func(args ...string) []map[string]any { rows := []map[string]any{} if out, err := dockerRun(ctx, args...); err == nil { jsonLines(out, func(m map[string]any) { rows = append(rows, m) }) } return rows } // Who uses what, from one inspect of every container: an image, a volume or a network // nothing uses is the only kind this page offers to remove. imageUsed := map[string]bool{} volumeUsers, networkUsers := map[string][]string{}, map[string][]string{} if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 { const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}` if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { p := strings.SplitN(line, "|", 4) if len(p) < 4 { continue } name := strings.TrimPrefix(p[0], "/") if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 { imageUsed[id[:12]] = true } for _, v := range strings.Split(p[2], ",") { if v != "" { volumeUsers[v] = append(volumeUsers[v], name) } } for _, n := range strings.Split(p[3], ",") { if n != "" { networkUsers[n] = append(networkUsers[n], name) } } } } } images := list("images", "--format", "{{json .}}") for _, im := range images { im["in_use"] = imageUsed[dstr(im, "ID")] } volumes := list("volume", "ls", "--format", "{{json .}}") for _, v := range volumes { users := volumeUsers[dstr(v, "Name")] if users == nil { users = []string{} } sort.Strings(users) v["used_by"] = users v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous") } networks := list("network", "ls", "--format", "{{json .}}") for _, n := range networks { users := networkUsers[dstr(n, "Name")] if users == nil { users = []string{} } sort.Strings(users) n["used_by"] = users n["predefined"] = predefinedNetwork[dstr(n, "Name")] } jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers, "images": images, "volumes": volumes, "networks": networks}) } var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) { id, action := r.PathValue("id"), r.PathValue("action") if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart" && action != "remove") { jsonErr(w, 400, "start, stop, restart or remove a container by name or ID") return } ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() verb := action if action == "remove" { // never forced: a running container has to be stopped first, on purpose verb = "rm" } if _, err := dockerRun(ctx, verb, id); err != nil { code := 500 if action == "remove" { code = 409 } jsonErr(w, code, err.Error()) return } log.Printf("docker: %s %s (from %s)", action, id, clientIP(r)) w.WriteHeader(204) } var imageRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,254}$`) // imagePull runs `docker pull` as a task the page follows. func (s *Server) imagePull(w http.ResponseWriter, r *http.Request) { var body struct{ Image string } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) { jsonErr(w, 400, "an image looks like nginx:alpine or ghcr.io/owner/name:tag") return } ref := strings.TrimSpace(body.Image) t, err := s.tasks.Start("docker:pull", "pull "+ref, exec.Command(dockerTool(), "pull", ref)) if err != nil { jsonErr(w, 500, err.Error()) return } log.Printf("docker: pull %s as task %s (from %s)", ref, t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) } // imageRemove is `docker rmi`, unforced: an image something still uses is refused by Docker, and that is the answer. func (s *Server) imageRemove(w http.ResponseWriter, r *http.Request) { var body struct{ Image string } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) { jsonErr(w, 400, "which image?") return } ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() if _, err := dockerRun(ctx, "rmi", strings.TrimSpace(body.Image)); err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("docker: rmi %s (from %s)", body.Image, clientIP(r)) w.WriteHeader(204) } // imagePrune removes dangling images — layers no tag points at any more, left behind by pulls — // or, asked for {"all": true}, every image no container uses. Those can all be pulled again. func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() var body struct{ All bool } _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) args := []string{"image", "prune", "-f"} if body.All { args = append(args, "-a") } out, err := dockerRun(ctx, args...) if err != nil { jsonErr(w, 500, err.Error()) return } lines := strings.Split(strings.TrimSpace(string(out)), "\n") log.Printf("docker: image prune (from %s)", clientIP(r)) jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) } // dockerLogs is the tail of a container's log; ?since= asks only for what is new, // which is how the page follows a log without holding a connection open. func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !dockerRef.MatchString(id) { jsonErr(w, 400, "bad container") return } args := []string{"logs", "--timestamps"} if since := r.URL.Query().Get("since"); since != "" { if _, err := time.Parse(time.RFC3339Nano, since); err != nil { jsonErr(w, 400, "since is an RFC 3339 time") return } args = append(args, "--since", since) } else { n, _ := strconv.Atoi(r.URL.Query().Get("n")) if n <= 0 || n > 5000 { n = 300 } args = append(args, "--tail", strconv.Itoa(n)) } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() out, err := dockerRun(ctx, append(args, id)...) if err != nil { code := 500 if strings.Contains(strings.ToLower(err.Error()), "no such container") { code = 404 } jsonErr(w, code, err.Error()) return } type line struct { T string `json:"t"` M string `json:"m"` } lines := []line{} for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { if l == "" { continue } t, m, ok := strings.Cut(l, " ") if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil { t, m = "", l } lines = append(lines, line{t, m}) } jsonOK(w, 200, map[string]any{"lines": lines}) } // dockerInspect is the parts of `docker inspect` worth reading on a phone. // dockerErr answers for a docker command that failed: a thing that isn't there is a 404, // the daemon being away a 503, anything else a 500. func dockerErr(w http.ResponseWriter, err error) { msg := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(err.Error()), "error: ")) low := strings.ToLower(msg) switch { case strings.Contains(low, "no such object") || strings.Contains(low, "no such container") || strings.Contains(low, "not found"): jsonErr(w, 404, msg) case strings.Contains(low, "cannot connect to the docker daemon") || strings.Contains(low, "is the docker daemon running"): jsonErr(w, 503, msg) default: jsonErr(w, 500, msg) } } func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !dockerRef.MatchString(id) { jsonErr(w, 400, "bad container") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() out, err := dockerRun(ctx, "inspect", id) if err != nil { dockerErr(w, err) return } var all []map[string]any if json.Unmarshal(out, &all) != nil || len(all) == 0 { jsonErr(w, 500, "inspect: unreadable answer") return } c := all[0] dig := func(m map[string]any, path ...string) any { var cur any = m for _, p := range path { mm, ok := cur.(map[string]any) if !ok { return nil } cur = mm[p] } return cur } env := []string{} if list, ok := dig(c, "Config", "Env").([]any); ok { for _, e := range list { k, _, _ := strings.Cut(fmt.Sprint(e), "=") env = append(env, k) // names only: values are where the secrets live } } sort.Strings(env) jsonOK(w, 200, map[string]any{ "image": dig(c, "Config", "Image"), "command": dig(c, "Config", "Cmd"), "entrypoint": dig(c, "Config", "Entrypoint"), "restart": dig(c, "HostConfig", "RestartPolicy", "Name"), "network_mode": dig(c, "HostConfig", "NetworkMode"), "memory_limit": dig(c, "HostConfig", "Memory"), "started": dig(c, "State", "StartedAt"), "exit_code": dig(c, "State", "ExitCode"), "error": dig(c, "State", "Error"), "restarts": c["RestartCount"], "mounts": c["Mounts"], "ports": dig(c, "NetworkSettings", "Ports"), "networks": dig(c, "NetworkSettings", "Networks"), "env_names": env, "compose_file": dig(c, "Config", "Labels", "com.docker.compose.project.config_files"), }) } var volumeRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$`) // volumeRemove deletes one volume and what is in it, which nothing brings back. So it is never // forced (Docker refuses a volume a container still uses), it is one at a time, and the request // has to name the volume twice — a slip of a script can't delete one by accident. func (s *Server) volumeRemove(w http.ResponseWriter, r *http.Request) { var body struct{ Name, Confirm string } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) { jsonErr(w, 400, "which volume?") return } if body.Confirm != body.Name { jsonErr(w, 400, "name the volume again in \"confirm\" to delete it") return } ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() if _, err := dockerRun(ctx, "volume", "rm", body.Name); err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("docker: volume rm %s (from %s)", body.Name, clientIP(r)) w.WriteHeader(204) } // volumePrune removes unused *anonymous* volumes only: the nameless ones an image's VOLUME line // leaves behind each time its container is recreated. Named volumes are somebody's data, and a // stack that is merely down leaves its volumes "unused" — those go one at a time, by name. func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() out, err := dockerRun(ctx, "volume", "prune", "-f") if err != nil { jsonErr(w, 500, err.Error()) return } lines := strings.Split(strings.TrimSpace(string(out)), "\n") log.Printf("docker: volume prune (from %s)", clientIP(r)) jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) } // The three networks Docker makes itself. They hold nothing, cannot be removed, and // their absence would break every container that asks for one by name. var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true} // networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an // image nothing has to be downloaded again — a stack that wants it back gets it recreated // on the next deploy. Docker refuses one a container is still attached to, which is the // check that matters, so this asks for no second confirmation. func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) { var body struct{ Name string } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) { jsonErr(w, 400, "which network?") return } if predefinedNetwork[body.Name] { jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed") return } ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) defer cancel() if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil { jsonErr(w, 409, dockerWhy(out, err)) return } log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r)) w.WriteHeader(204) } // networkPrune removes every network no container is attached to. A stack that is merely // down counts as unattached, so its network goes too — and comes back on the next deploy, // with a new subnet. That is the whole cost, and the caller is told it. func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) defer cancel() out, err := dockerRun(ctx, "network", "prune", "-f") if err != nil { jsonErr(w, 500, err.Error()) return } lines := strings.Split(strings.TrimSpace(string(out)), "\n") log.Printf("docker: network prune (from %s)", clientIP(r)) jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) } // ---- a container's health, its recent life, its console, and its limits ---- // dockerHealth is the health check's verdict and the last few probe results. Docker keeps // the last five itself, which is all anyone wants: is it failing, and what did it say. func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !dockerRef.MatchString(id) { jsonErr(w, 400, "bad container") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id) if err != nil { dockerErr(w, err) return } var st struct { Status string `json:"Status"` Health *struct { Status string `json:"Status"` FailingStreak int `json:"FailingStreak"` Log []struct { Start time.Time `json:"Start"` End time.Time `json:"End"` ExitCode int `json:"ExitCode"` Output string `json:"Output"` } `json:"Log"` } `json:"Health"` } if json.Unmarshal(out, &st) != nil { jsonErr(w, 500, "could not read the container's state") return } if st.Health == nil { jsonOK(w, 200, map[string]any{"configured": false, "state": st.Status}) return } jsonOK(w, 200, map[string]any{ "configured": true, "state": st.Status, "status": st.Health.Status, "failing_streak": st.Health.FailingStreak, "log": st.Health.Log, }) } // dockerExec opens a shell inside a container, as a terminal session like any other — so // it reattaches, resizes and scrolls back the same way, and works on a managed machine // through the same proxy. func (s *Server) dockerExec(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !dockerRef.MatchString(id) { jsonErr(w, 400, "bad container") return } tool := dockerTool() if tool == "" { jsonErr(w, 500, "neither docker nor podman is installed here") return } var body struct { Shell string `json:"shell"` } _ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body) shell := strings.TrimSpace(body.Shell) if shell == "" { // most images have sh; bash is nicer where it exists, so try it first and fall back shell = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi" } else if strings.ContainsAny(shell, "\n\r") { jsonErr(w, 400, "bad shell") return } argv := []string{tool, "exec", "-it", id, "sh", "-c", shell} if priv.Available() && !dockerReachable() { argv = priv.Wrap(argv...) } sess, err := s.mgr.CreateCmd("", "sh "+shortName(id), argv) if err != nil { jsonErr(w, 500, err.Error()) return } log.Printf("docker exec into %s as terminal %s by %s", id, sess.ID, clientIP(r)) jsonOK(w, 200, map[string]string{"id": sess.ID, "name": "sh " + shortName(id)}) } func shortName(id string) string { if len(id) > 20 { return id[:20] } return id } // dockerReachable reports whether docker answers without help. func dockerReachable() bool { tool := dockerTool() if tool == "" { return false } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return exec.CommandContext(ctx, tool, "version", "--format", "{{.Server.Version}}").Run() == nil } // dockerUpdate changes a running container's limits and restart policy. `docker update` // does this live, without recreating it and without touching the compose file — which is // also the catch, and the caller is told: a deploy from compose puts back whatever compose // says. func (s *Server) dockerUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !dockerRef.MatchString(id) { jsonErr(w, 400, "bad container") return } var body struct { Memory string `json:"memory"` // "512m", "2g", or "" to leave alone; "0" removes the limit CPUs string `json:"cpus"` // "1.5", or "0" for no limit Restart string `json:"restart"` // no | on-failure | unless-stopped | always } if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { jsonErr(w, 400, "bad request") return } args := []string{"update"} if m := strings.TrimSpace(body.Memory); m != "" { if !regexp.MustCompile(`^[0-9]+([bkmgBKMG])?$`).MatchString(m) { jsonErr(w, 400, "memory: a number, optionally with b, k, m or g — 0 removes the limit") return } args = append(args, "--memory", m, "--memory-swap", m) } if c := strings.TrimSpace(body.CPUs); c != "" { if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`).MatchString(c) { jsonErr(w, 400, "cpus: a number like 1.5 — 0 removes the limit") return } args = append(args, "--cpus", c) } if p := strings.TrimSpace(body.Restart); p != "" { switch p { case "no", "on-failure", "unless-stopped", "always": default: jsonErr(w, 400, "restart: no, on-failure, unless-stopped or always") return } args = append(args, "--restart", p) } if len(args) == 1 { jsonErr(w, 400, "nothing to change") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() if _, err := dockerRun(ctx, append(args, id)...); err != nil { jsonErr(w, 500, err.Error()) return } log.Printf("docker update %s %v by %s", id, args[1:], clientIP(r)) w.WriteHeader(204) }