diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 6967c4b..7eeaf50 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -8,7 +8,8 @@ // emailed code, then offers $99 Founding Lifetime (CoinPay) // ssh pod@host your personal Linux pod — free for verified members // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) -// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only) +// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only; +// sysop@/root@ are aliases) // ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, // agent-vs-agent (also on wss://host/play). See docs/agentgames.md // diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 79ea516..f1f287e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,8 +43,9 @@ var DomainNames = map[string]bool{"domain": true, "domains": true} // AdminNames are usernames that route to the privileged admin console (PRD §6). // The route only opens for accounts whose name is in the operator allowlist -// (see IsAdmin); the name itself confers nothing. -var AdminNames = map[string]bool{"admin": true, "sysop": true} +// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar +// alias here, not a backdoor. +var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true} // TorURLNames route to the one-shot "fetch a URL over Tor" command (premium). var TorURLNames = map[string]bool{"tor-url": true} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index aec7996..9e08477 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -3,7 +3,7 @@ package auth import "testing" func TestIsAdminName(t *testing.T) { - for _, name := range []string{"admin", "ADMIN", "sysop"} { + for _, name := range []string{"admin", "ADMIN", "sysop", "root"} { if !IsAdminName(name) { t.Errorf("IsAdminName(%q) = false, want true", name) } diff --git a/internal/pods/pods.go b/internal/pods/pods.go index ef6e87e..5c0d827 100644 --- a/internal/pods/pods.go +++ b/internal/pods/pods.go @@ -107,7 +107,6 @@ 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{ @@ -149,28 +148,9 @@ func (m *Manager) ensure(user string) (string, error) { 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/scripts/rebuild-pods.sh b/scripts/rebuild-pods.sh new file mode 100755 index 0000000..0cc3fb0 --- /dev/null +++ b/scripts/rebuild-pods.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Rebuild every AgentBBS member pod so it picks up the current container profile +# (e.g. the rootless-podman default capability set added for apt/chown/su/:80). +# +# It removes each pod CONTAINER but keeps that pod's named home volume +# (agentbbs-pod--home) and the host-side public_html, so member data and +# websites are untouched. Pods are recreated automatically — with the new +# profile — the next time each member runs `ssh pod@`. Caddy serves +# public_html from the host, so sites stay up while a pod is briefly down. +# +# Anything a member installed into the pod's system rootfs (apt packages, etc.) +# is lost on rebuild; only /home/dev and public_html persist. +# +# Run this as the user that owns the pods. For rootless podman that's the +# AgentBBS service user (pods are per-user), not necessarily root. +# +# Usage: +# scripts/rebuild-pods.sh # list, then prompt before removing +# scripts/rebuild-pods.sh --yes # non-interactive (for cron/deploy) +# AGENTBBS_POD_ENGINE=docker scripts/rebuild-pods.sh # force engine +set -euo pipefail + +ENGINE="${AGENTBBS_POD_ENGINE:-}" +if [ -z "$ENGINE" ]; then + if command -v podman >/dev/null 2>&1; then + ENGINE=podman + elif command -v docker >/dev/null 2>&1; then + ENGINE=docker + else + echo "rebuild-pods: neither podman nor docker found" >&2 + exit 1 + fi +fi + +mapfile -t pods < <("$ENGINE" ps -a --filter 'name=agentbbs-pod-' --format '{{.Names}}' | sort) + +if [ "${#pods[@]}" -eq 0 ]; then + echo "rebuild-pods: no pods found (engine: $ENGINE)" + exit 0 +fi + +echo "Found ${#pods[@]} pod(s) via $ENGINE:" +printf ' %s\n' "${pods[@]}" + +if [ "${1:-}" != "--yes" ] && [ "${1:-}" != "-y" ]; then + printf 'Remove these containers (home volumes kept)? [y/N] ' + read -r reply + case "$reply" in + y | Y | yes | YES) ;; + *) + echo "aborted" + exit 0 + ;; + esac +fi + +for p in "${pods[@]}"; do + # No -v: named home volumes are preserved, only the container is destroyed. + "$ENGINE" rm -f "$p" >/dev/null && echo "removed $p" +done + +echo +echo "Done. Each pod recreates with the new profile on its owner's next 'ssh pod@'."