Merge pull request #25 from profullstack/fix/pod-public-html-bind-mount

fix(pods): bind-mount host public_html into the pod so ~/public_html is served
This commit is contained in:
Anthony Ettinger 2026-06-15 06:44:37 -07:00 committed by GitHub
commit cbe7a44bd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 129 additions and 22 deletions

View file

@ -191,7 +191,7 @@ func main() {
}() }()
} }
if m, err := pods.Detect(); err == nil { if m, err := pods.Detect(filepath.Join(dataDir, "users")); err == nil {
a.pods = m a.pods = m
log.Info("pods enabled", "engine", m.Engine()) log.Info("pods enabled", "engine", m.Engine())
} else { } else {

View file

@ -2,9 +2,14 @@
// (`ssh pod@host`) — "run shit in a docker-like" without root on the host. // (`ssh pod@host`) — "run shit in a docker-like" without root on the host.
// //
// Engine preference: rootless Podman (daemonless; container root maps to an // Engine preference: rootless Podman (daemonless; container root maps to an
// unprivileged host uid via user namespaces), falling back to Docker with a // unprivileged host uid via user namespaces), falling back to Docker.
// 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. // 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 package pods
import ( import (
@ -12,6 +17,7 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -24,26 +30,46 @@ import (
type Manager struct { type Manager struct {
engine string // "podman" or "docker" engine string // "podman" or "docker"
image string image string
usersDir string // <data>/users on the host; when set, <user>/public_html is bind-mounted into the pod
mu sync.Mutex mu sync.Mutex
attached map[string]int // container name -> live session count attached map[string]int // container name -> live session count
} }
// Detect picks the best available engine. Returns an error if neither // Detect picks the best available engine. Returns an error if neither
// podman nor docker is present. // podman nor docker is present. usersDir is the host <data>/users tree: when
func Detect() (*Manager, error) { // non-empty, each pod bind-mounts <usersDir>/<user>/public_html at
// /home/dev/public_html so a member's ~/public_html is exactly the directory
// Caddy serves at <name>.<host>. Pass "" to disable the bind.
func Detect(usersDir string) (*Manager, error) {
image := os.Getenv("AGENTBBS_POD_IMAGE") image := os.Getenv("AGENTBBS_POD_IMAGE")
if image == "" { if image == "" {
image = "debian:stable-slim" image = "debian:stable-slim"
} }
for _, eng := range []string{"podman", "docker"} { for _, eng := range []string{"podman", "docker"} {
if _, err := exec.LookPath(eng); err == nil { 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") 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. // Engine reports the active container engine.
func (m *Manager) Engine() string { return m.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. // ensure creates (or starts) the user's container and returns its name.
func (m *Manager) ensure(user string) (string, error) { func (m *Manager) ensure(user string) (string, error) {
name := m.containerName(user) 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 <name>.<host>.
_, pubSpec := m.publicHTMLMount(user)
if m.engine == "docker" { if m.engine == "docker" {
// Under docker the pod runs as uid 1000 (never container root), so // Under docker the pod runs as uid 1000 (never container root), so the
// the named home volume must be owned by 1000. A trusted one-shot // named home volume — and the bind-mounted public_html — must be owned
// init container enforces that on every ensure — volumes can predate // by 1000. A trusted one-shot init container enforces that on every
// the container or survive recreation. Rootless podman doesn't need // ensure — mounts can predate the container or survive recreation.
// this: container root maps to the unprivileged host user. // Rootless podman doesn't need this: container root maps to the
init := exec.Command(m.engine, "run", "--rm", // unprivileged host user that already owns these trees.
"-v", name+"-home:/home/dev", m.image, initArgs := []string{"run", "--rm", "-v", name + "-home:/home/dev"}
"sh", "-c", "chown 1000:1000 /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 { if out, err := init.CombinedOutput(); err != nil {
return "", fmt.Errorf("pods: volume init failed: %v: %s", err, strings.TrimSpace(string(out))) 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? // Already exists?
if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil { if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil {
_ = exec.Command(m.engine, "start", name).Run() // no-op if running _ = exec.Command(m.engine, "start", name).Run() // no-op if running
m.tuneApt(name)
return name, nil return name, nil
} }
args := []string{ args := []string{
@ -81,26 +117,60 @@ func (m *Manager) ensure(user string) (string, error) {
"--memory", env("AGENTBBS_POD_MEM", "512m"), "--memory", env("AGENTBBS_POD_MEM", "512m"),
"--cpus", env("AGENTBBS_POD_CPUS", "1"), "--cpus", env("AGENTBBS_POD_CPUS", "1"),
"--pids-limit", "256", "--pids-limit", "256",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--restart", "unless-stopped", "--restart", "unless-stopped",
"-v", name + "-home:/home/dev", "-v", name + "-home:/home/dev",
"-w", "/home/dev", "-w", "/home/dev",
"-e", "HOME=/home/dev", "-e", "HOME=/home/dev",
} }
if pubSpec != "" {
args = append(args, "-v", pubSpec)
}
if m.engine == "docker" { if m.engine == "docker" {
// Rootless podman user-ns maps container root safely; under docker, // Rootful docker: a breakout is host-root, so refuse to hand out
// refuse to hand out container root at all. // container root — run as uid 1000 with no caps and no privilege
args = append(args, "--user", "1000:1000") // 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") args = append(args, m.image, "sleep", "infinity")
out, err := exec.Command(m.engine, args...).CombinedOutput() out, err := exec.Command(m.engine, args...).CombinedOutput()
if err != nil { if err != nil {
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out))) return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
} }
m.tuneApt(name)
return name, nil 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. // Attach provisions the pod and wires the SSH session to a shell inside it.
// Blocks until the shell exits or the session closes. // Blocks until the shell exits or the session closes.
func (m *Manager) Attach(s ssh.Session, user string) error { func (m *Manager) Attach(s ssh.Session, user string) error {

View file

@ -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)
}
}