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. // Creating, removing and pruning are deliberately absent: whatever deployed the stacks // (Dockhand, compose) stays the one place that changes what exists. 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. 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 } 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 } jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers, "images": list("images", "--format", "{{json .}}"), "volumes": list("volume", "ls", "--format", "{{json .}}"), "networks": list("network", "ls", "--format", "{{json .}}")}) } 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 only — layers no tag points at any more, left behind by pulls. func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() out, err := dockerRun(ctx, "image", "prune", "-f") 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. 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) var all []map[string]any if err != nil || json.Unmarshal(out, &all) != nil || len(all) == 0 { jsonErr(w, 500, fmt.Sprint("inspect failed: ", err)) 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"), }) }