Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
112 changes: 91 additions & 21 deletions internal/pods/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@
// (`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 (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
Expand All @@ -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 // <data>/users on the host; when set, <user>/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 <data>/users tree: when
// 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")
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 }

Expand All @@ -56,22 +82,32 @@ 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 <name>.<host>.
_, 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)))
}
}
// 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{
Expand All @@ -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 {
Expand Down
37 changes: 37 additions & 0 deletions internal/pods/pods_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading