Skip to content

Commit cbe7a44

Browse files
authored
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
2 parents c480e62 + 06bc563 commit cbe7a44

3 files changed

Lines changed: 129 additions & 22 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func main() {
191191
}()
192192
}
193193

194-
if m, err := pods.Detect(); err == nil {
194+
if m, err := pods.Detect(filepath.Join(dataDir, "users")); err == nil {
195195
a.pods = m
196196
log.Info("pods enabled", "engine", m.Engine())
197197
} else {

internal/pods/pods.go

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@
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.
813
package pods
914

1015
import (
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.
2430
type 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.
4874
func (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.
5783
func (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.
106176
func (m *Manager) Attach(s ssh.Session, user string) error {

internal/pods/pods_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package pods
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// When no users dir is configured the bind is disabled: both return values are
10+
// empty and no directory is created.
11+
func TestPublicHTMLMountDisabled(t *testing.T) {
12+
m := &Manager{} // usersDir == ""
13+
host, spec := m.publicHTMLMount("chovy")
14+
if host != "" || spec != "" {
15+
t.Fatalf("expected empty host/spec when usersDir unset, got host=%q spec=%q", host, spec)
16+
}
17+
}
18+
19+
// With a users dir set, the member's public_html is created on the host and the
20+
// volume spec maps it to /home/dev/public_html in the pod.
21+
func TestPublicHTMLMountCreatesAndMaps(t *testing.T) {
22+
users := t.TempDir()
23+
m := &Manager{usersDir: users}
24+
25+
host, spec := m.publicHTMLMount("chovy")
26+
27+
wantHost := filepath.Join(users, "chovy", "public_html")
28+
if host != wantHost {
29+
t.Fatalf("host = %q, want %q", host, wantHost)
30+
}
31+
if want := wantHost + ":/home/dev/public_html"; spec != want {
32+
t.Fatalf("spec = %q, want %q", spec, want)
33+
}
34+
if fi, err := os.Stat(wantHost); err != nil || !fi.IsDir() {
35+
t.Fatalf("expected %q to be a created directory, err=%v", wantHost, err)
36+
}
37+
}

0 commit comments

Comments
 (0)