feat(pods/admin): root admin alias, default-caps cleanup, pod rebuild script

- auth: add `root` as an admin-console route alias (alongside admin/sysop);
  still gated by $AGENTBBS_ADMINS — the name confers nothing on its own.
- pods: drop the now-redundant tuneApt apt-sandbox hack. Rootless podman keeps
  its default capability set, so apt/chown/su work without disabling the
  download sandbox.
- scripts/rebuild-pods.sh: recreate all member pods (keeps home volumes) so
  they pick up the current container profile on next `ssh pod@`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-15 13:57:09 +00:00
parent 06bc5631d8
commit a8cb7e3182
5 changed files with 69 additions and 24 deletions

View file

@ -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
//

View file

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

View file

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

View file

@ -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 {

63
scripts/rebuild-pods.sh Executable file
View file

@ -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-<name>-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@<host>`. 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@'."