22// (`ssh pod@host`) — "run shit in a docker-like" without root on the host.
33//
44// Engine preference: rootless Podman (daemonless; container root maps to an
5- // unprivileged host uid via user namespaces), falling back to Docker with a
6- // hardened profile (cap-drop ALL, no-new-privileges, non-root user, cpu/mem/
7- // pids caps). Either way the SSH user never touches the host OS.
5+ // unprivileged host uid via user namespaces), falling back to Docker.
6+ //
7+ // Capability profile differs by engine. Under rootless podman the host is
8+ // protected by the userns mapping itself, so pods keep podman's default
9+ // capability set — the pod's root behaves like real root (apt, chown, su,
10+ // binding :80, ping). Under docker a breakout is host-root, so docker pods run
11+ // non-root (uid 1000) with cap-drop ALL + no-new-privileges. Either way the SSH
12+ // user never touches the host OS. cpu/mem/pids limits apply to both.
813package pods
914
1015import (
1116 "fmt"
1217 "io"
1318 "os"
1419 "os/exec"
20+ "path/filepath"
1521 "regexp"
1622 "strings"
1723 "sync"
@@ -22,28 +28,48 @@ import (
2228
2329// Manager provisions and attaches per-user pods.
2430type Manager struct {
25- engine string // "podman" or "docker"
26- image string
31+ engine string // "podman" or "docker"
32+ image string
33+ usersDir string // <data>/users on the host; when set, <user>/public_html is bind-mounted into the pod
2734
2835 mu sync.Mutex
2936 attached map [string ]int // container name -> live session count
3037}
3138
3239// Detect picks the best available engine. Returns an error if neither
33- // podman nor docker is present.
34- func Detect () (* Manager , error ) {
40+ // podman nor docker is present. usersDir is the host <data>/users tree: when
41+ // non-empty, each pod bind-mounts <usersDir>/<user>/public_html at
42+ // /home/dev/public_html so a member's ~/public_html is exactly the directory
43+ // Caddy serves at <name>.<host>. Pass "" to disable the bind.
44+ func Detect (usersDir string ) (* Manager , error ) {
3545 image := os .Getenv ("AGENTBBS_POD_IMAGE" )
3646 if image == "" {
3747 image = "debian:stable-slim"
3848 }
3949 for _ , eng := range []string {"podman" , "docker" } {
4050 if _ , err := exec .LookPath (eng ); err == nil {
41- return & Manager {engine : eng , image : image , attached : map [string ]int {}}, nil
51+ return & Manager {engine : eng , image : image , usersDir : usersDir , attached : map [string ]int {}}, nil
4252 }
4353 }
4454 return nil , fmt .Errorf ("pods: neither podman nor docker found" )
4555}
4656
57+ // publicHTMLMount returns the host path to the member's public_html and the
58+ // "-v src:dst" volume spec that maps it into the pod, or "" for both when the
59+ // bind is disabled. The host directory is created if absent so the mount source
60+ // exists before the container starts.
61+ func (m * Manager ) publicHTMLMount (user string ) (host , spec string ) {
62+ if m .usersDir == "" {
63+ return "" , ""
64+ }
65+ host = filepath .Join (m .usersDir , user , "public_html" )
66+ if err := os .MkdirAll (host , 0o755 ); err != nil {
67+ // Non-fatal: fall back to the named-volume-only pod (no homepage bind).
68+ return "" , ""
69+ }
70+ return host , host + ":/home/dev/public_html"
71+ }
72+
4773// Engine reports the active container engine.
4874func (m * Manager ) Engine () string { return m .engine }
4975
@@ -56,22 +82,32 @@ func (m *Manager) containerName(user string) string {
5682// ensure creates (or starts) the user's container and returns its name.
5783func (m * Manager ) ensure (user string ) (string , error ) {
5884 name := m .containerName (user )
85+ // Bind the host's public_html into the pod so a member's edits at
86+ // ~/public_html are exactly what Caddy serves at <name>.<host>.
87+ _ , pubSpec := m .publicHTMLMount (user )
5988 if m .engine == "docker" {
60- // Under docker the pod runs as uid 1000 (never container root), so
61- // the named home volume must be owned by 1000. A trusted one-shot
62- // init container enforces that on every ensure — volumes can predate
63- // the container or survive recreation. Rootless podman doesn't need
64- // this: container root maps to the unprivileged host user.
65- init := exec .Command (m .engine , "run" , "--rm" ,
66- "-v" , name + "-home:/home/dev" , m .image ,
67- "sh" , "-c" , "chown 1000:1000 /home/dev" )
89+ // Under docker the pod runs as uid 1000 (never container root), so the
90+ // named home volume — and the bind-mounted public_html — must be owned
91+ // by 1000. A trusted one-shot init container enforces that on every
92+ // ensure — mounts can predate the container or survive recreation.
93+ // Rootless podman doesn't need this: container root maps to the
94+ // unprivileged host user that already owns these trees.
95+ initArgs := []string {"run" , "--rm" , "-v" , name + "-home:/home/dev" }
96+ chown := "chown 1000:1000 /home/dev"
97+ if pubSpec != "" {
98+ initArgs = append (initArgs , "-v" , pubSpec )
99+ chown += "; chown 1000:1000 /home/dev/public_html"
100+ }
101+ initArgs = append (initArgs , m .image , "sh" , "-c" , chown )
102+ init := exec .Command (m .engine , initArgs ... )
68103 if out , err := init .CombinedOutput (); err != nil {
69104 return "" , fmt .Errorf ("pods: volume init failed: %v: %s" , err , strings .TrimSpace (string (out )))
70105 }
71106 }
72107 // Already exists?
73108 if err := exec .Command (m .engine , "container" , "inspect" , name ).Run (); err == nil {
74109 _ = exec .Command (m .engine , "start" , name ).Run () // no-op if running
110+ m .tuneApt (name )
75111 return name , nil
76112 }
77113 args := []string {
@@ -81,26 +117,60 @@ func (m *Manager) ensure(user string) (string, error) {
81117 "--memory" , env ("AGENTBBS_POD_MEM" , "512m" ),
82118 "--cpus" , env ("AGENTBBS_POD_CPUS" , "1" ),
83119 "--pids-limit" , "256" ,
84- "--cap-drop" , "ALL" ,
85- "--security-opt" , "no-new-privileges" ,
86120 "--restart" , "unless-stopped" ,
87121 "-v" , name + "-home:/home/dev" ,
88122 "-w" , "/home/dev" ,
89123 "-e" , "HOME=/home/dev" ,
90124 }
125+ if pubSpec != "" {
126+ args = append (args , "-v" , pubSpec )
127+ }
91128 if m .engine == "docker" {
92- // Rootless podman user-ns maps container root safely; under docker,
93- // refuse to hand out container root at all.
94- args = append (args , "--user" , "1000:1000" )
129+ // Rootful docker: a breakout is host-root, so refuse to hand out
130+ // container root — run as uid 1000 with no caps and no privilege
131+ // escalation.
132+ args = append (args ,
133+ "--user" , "1000:1000" ,
134+ "--cap-drop" , "ALL" ,
135+ "--security-opt" , "no-new-privileges" ,
136+ )
137+ } else {
138+ // Rootless podman: container root is already an unprivileged host uid
139+ // via user namespaces, so keep podman's default capability set and let
140+ // the pod's root act like real root (apt, chown, su, binding :80,
141+ // ping). Power users can opt into extra caps (e.g. SYS_PTRACE for
142+ // strace, NET_ADMIN for iptables) via AGENTBBS_POD_EXTRA_CAPS.
143+ for _ , c := range strings .Fields (strings .ReplaceAll (env ("AGENTBBS_POD_EXTRA_CAPS" , "" ), "," , " " )) {
144+ args = append (args , "--cap-add" , c )
145+ }
95146 }
96147 args = append (args , m .image , "sleep" , "infinity" )
97148 out , err := exec .Command (m .engine , args ... ).CombinedOutput ()
98149 if err != nil {
99150 return "" , fmt .Errorf ("pods: create failed: %v: %s" , err , strings .TrimSpace (string (out )))
100151 }
152+ m .tuneApt (name )
101153 return name , nil
102154}
103155
156+ // tuneApt makes apt usable inside the hardened pod. apt drops privileges to
157+ // the _apt user for downloads (setgroups/setegid/seteuid), which needs
158+ // CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL).
159+ // Rather than re-grant those to the whole container, disable apt's download
160+ // sandbox so package management runs as the pod's (rootless-mapped) root.
161+ //
162+ // Only applies to the podman/container-root path; under docker the pod runs as
163+ // uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure
164+ // is non-fatal: a missing config just means the user sees the old apt errors.
165+ func (m * Manager ) tuneApt (name string ) {
166+ if m .engine == "docker" {
167+ return
168+ }
169+ _ = exec .Command (m .engine , "exec" , "--user" , "root" , name ,
170+ "sh" , "-c" , `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox` ,
171+ ).Run ()
172+ }
173+
104174// Attach provisions the pod and wires the SSH session to a shell inside it.
105175// Blocks until the shell exits or the session closes.
106176func (m * Manager ) Attach (s ssh.Session , user string ) error {
0 commit comments