From 06bc5631d8c59f4130d72710ae5939571468fd23 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 15 Jun 2026 13:42:08 +0000 Subject: [PATCH] fix(pods): bind-mount host public_html into the pod so ~/public_html is served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member's pod home (/home/dev) is a named container volume, but Caddy serves . from the host path /users//public_html. The two were disconnected, so editing ~/public_html/index.html in the pod never changed the served page — contradicting the on-screen "edit ~/public_html to make it yours" instruction. Bind-mount /users//public_html at /home/dev/public_html when pods start. The host tree is created if absent so the mount source exists, and under the docker fallback (uid 1000) the one-shot init container now also chowns the bind path; rootless podman maps container root to the host service user that already owns the tree, so no chown is needed there. Detect now takes the host users dir; pass "" to disable the bind. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 2 +- internal/pods/pods.go | 112 ++++++++++++++++++++++++++++++------- internal/pods/pods_test.go | 37 ++++++++++++ 3 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 internal/pods/pods_test.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 85ac2ef..6967c4b 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -189,7 +189,7 @@ func main() { }() } - if m, err := pods.Detect(); err == nil { + if m, err := pods.Detect(filepath.Join(dataDir, "users")); err == nil { a.pods = m log.Info("pods enabled", "engine", m.Engine()) } else { diff --git a/internal/pods/pods.go b/internal/pods/pods.go index ea638c8..ef6e87e 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -2,9 +2,14 @@ // (`ssh pod@host`) — "run shit in a docker-like" without root on the host. // // Engine preference: rootless Podman (daemonless; container root maps to an -// unprivileged host uid via user namespaces), falling back to Docker with a -// hardened profile (cap-drop ALL, no-new-privileges, non-root user, cpu/mem/ -// pids caps). Either way the SSH user never touches the host OS. +// unprivileged host uid via user namespaces), falling back to Docker. +// +// Capability profile differs by engine. Under rootless podman the host is +// protected by the userns mapping itself, so pods keep podman's default +// capability set — the pod's root behaves like real root (apt, chown, su, +// binding :80, ping). Under docker a breakout is host-root, so docker pods run +// non-root (uid 1000) with cap-drop ALL + no-new-privileges. Either way the SSH +// user never touches the host OS. cpu/mem/pids limits apply to both. package pods import ( @@ -12,6 +17,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "regexp" "strings" "sync" @@ -22,28 +28,48 @@ import ( // Manager provisions and attaches per-user pods. type Manager struct { - engine string // "podman" or "docker" - image string + engine string // "podman" or "docker" + image string + usersDir string // /users on the host; when set, /public_html is bind-mounted into the pod mu sync.Mutex attached map[string]int // container name -> live session count } // Detect picks the best available engine. Returns an error if neither -// podman nor docker is present. -func Detect() (*Manager, error) { +// podman nor docker is present. usersDir is the host /users tree: when +// non-empty, each pod bind-mounts //public_html at +// /home/dev/public_html so a member's ~/public_html is exactly the directory +// Caddy serves at .. Pass "" to disable the bind. +func Detect(usersDir string) (*Manager, error) { image := os.Getenv("AGENTBBS_POD_IMAGE") if image == "" { image = "debian:stable-slim" } for _, eng := range []string{"podman", "docker"} { if _, err := exec.LookPath(eng); err == nil { - return &Manager{engine: eng, image: image, attached: map[string]int{}}, nil + return &Manager{engine: eng, image: image, usersDir: usersDir, attached: map[string]int{}}, nil } } return nil, fmt.Errorf("pods: neither podman nor docker found") } +// publicHTMLMount returns the host path to the member's public_html and the +// "-v src:dst" volume spec that maps it into the pod, or "" for both when the +// bind is disabled. The host directory is created if absent so the mount source +// exists before the container starts. +func (m *Manager) publicHTMLMount(user string) (host, spec string) { + if m.usersDir == "" { + return "", "" + } + host = filepath.Join(m.usersDir, user, "public_html") + if err := os.MkdirAll(host, 0o755); err != nil { + // Non-fatal: fall back to the named-volume-only pod (no homepage bind). + return "", "" + } + return host, host + ":/home/dev/public_html" +} + // Engine reports the active container engine. func (m *Manager) Engine() string { return m.engine } @@ -56,15 +82,24 @@ func (m *Manager) containerName(user string) string { // ensure creates (or starts) the user's container and returns its name. func (m *Manager) ensure(user string) (string, error) { name := m.containerName(user) + // Bind the host's public_html into the pod so a member's edits at + // ~/public_html are exactly what Caddy serves at .. + _, pubSpec := m.publicHTMLMount(user) if m.engine == "docker" { - // Under docker the pod runs as uid 1000 (never container root), so - // the named home volume must be owned by 1000. A trusted one-shot - // init container enforces that on every ensure — volumes can predate - // the container or survive recreation. Rootless podman doesn't need - // this: container root maps to the unprivileged host user. - init := exec.Command(m.engine, "run", "--rm", - "-v", name+"-home:/home/dev", m.image, - "sh", "-c", "chown 1000:1000 /home/dev") + // Under docker the pod runs as uid 1000 (never container root), so the + // named home volume — and the bind-mounted public_html — must be owned + // by 1000. A trusted one-shot init container enforces that on every + // ensure — mounts can predate the container or survive recreation. + // Rootless podman doesn't need this: container root maps to the + // unprivileged host user that already owns these trees. + initArgs := []string{"run", "--rm", "-v", name + "-home:/home/dev"} + chown := "chown 1000:1000 /home/dev" + if pubSpec != "" { + initArgs = append(initArgs, "-v", pubSpec) + chown += "; chown 1000:1000 /home/dev/public_html" + } + initArgs = append(initArgs, m.image, "sh", "-c", chown) + init := exec.Command(m.engine, initArgs...) if out, err := init.CombinedOutput(); err != nil { return "", fmt.Errorf("pods: volume init failed: %v: %s", err, strings.TrimSpace(string(out))) } @@ -72,6 +107,7 @@ func (m *Manager) ensure(user string) (string, error) { // Already exists? if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil { _ = exec.Command(m.engine, "start", name).Run() // no-op if running + m.tuneApt(name) return name, nil } args := []string{ @@ -81,26 +117,60 @@ func (m *Manager) ensure(user string) (string, error) { "--memory", env("AGENTBBS_POD_MEM", "512m"), "--cpus", env("AGENTBBS_POD_CPUS", "1"), "--pids-limit", "256", - "--cap-drop", "ALL", - "--security-opt", "no-new-privileges", "--restart", "unless-stopped", "-v", name + "-home:/home/dev", "-w", "/home/dev", "-e", "HOME=/home/dev", } + if pubSpec != "" { + args = append(args, "-v", pubSpec) + } if m.engine == "docker" { - // Rootless podman user-ns maps container root safely; under docker, - // refuse to hand out container root at all. - args = append(args, "--user", "1000:1000") + // Rootful docker: a breakout is host-root, so refuse to hand out + // container root — run as uid 1000 with no caps and no privilege + // escalation. + args = append(args, + "--user", "1000:1000", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + ) + } else { + // Rootless podman: container root is already an unprivileged host uid + // via user namespaces, so keep podman's default capability set and let + // the pod's root act like real root (apt, chown, su, binding :80, + // ping). Power users can opt into extra caps (e.g. SYS_PTRACE for + // strace, NET_ADMIN for iptables) via AGENTBBS_POD_EXTRA_CAPS. + for _, c := range strings.Fields(strings.ReplaceAll(env("AGENTBBS_POD_EXTRA_CAPS", ""), ",", " ")) { + args = append(args, "--cap-add", c) + } } args = append(args, m.image, "sleep", "infinity") out, err := exec.Command(m.engine, args...).CombinedOutput() if err != nil { return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out))) } + m.tuneApt(name) return name, nil } +// tuneApt makes apt usable inside the hardened pod. apt drops privileges to +// the _apt user for downloads (setgroups/setegid/seteuid), which needs +// CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL). +// Rather than re-grant those to the whole container, disable apt's download +// sandbox so package management runs as the pod's (rootless-mapped) root. +// +// Only applies to the podman/container-root path; under docker the pod runs as +// uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure +// is non-fatal: a missing config just means the user sees the old apt errors. +func (m *Manager) tuneApt(name string) { + if m.engine == "docker" { + return + } + _ = exec.Command(m.engine, "exec", "--user", "root", name, + "sh", "-c", `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox`, + ).Run() +} + // Attach provisions the pod and wires the SSH session to a shell inside it. // Blocks until the shell exits or the session closes. func (m *Manager) Attach(s ssh.Session, user string) error { diff --git a/internal/pods/pods_test.go b/internal/pods/pods_test.go new file mode 100644 index 0000000..2422d0a --- /dev/null +++ b/internal/pods/pods_test.go @@ -0,0 +1,37 @@ +package pods + +import ( + "os" + "path/filepath" + "testing" +) + +// When no users dir is configured the bind is disabled: both return values are +// empty and no directory is created. +func TestPublicHTMLMountDisabled(t *testing.T) { + m := &Manager{} // usersDir == "" + host, spec := m.publicHTMLMount("chovy") + if host != "" || spec != "" { + t.Fatalf("expected empty host/spec when usersDir unset, got host=%q spec=%q", host, spec) + } +} + +// With a users dir set, the member's public_html is created on the host and the +// volume spec maps it to /home/dev/public_html in the pod. +func TestPublicHTMLMountCreatesAndMaps(t *testing.T) { + users := t.TempDir() + m := &Manager{usersDir: users} + + host, spec := m.publicHTMLMount("chovy") + + wantHost := filepath.Join(users, "chovy", "public_html") + if host != wantHost { + t.Fatalf("host = %q, want %q", host, wantHost) + } + if want := wantHost + ":/home/dev/public_html"; spec != want { + t.Fatalf("spec = %q, want %q", spec, want) + } + if fi, err := os.Stat(wantHost); err != nil || !fi.IsDir() { + t.Fatalf("expected %q to be a created directory, err=%v", wantHost, err) + } +}