From b0b1300314d70e75d69eaee1556e2100485bbadf Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 26 Apr 2026 12:55:18 -0300 Subject: [PATCH] docs: add the privilege model document Explain what runs as the owner user vs root, every helper RPC method and its validation gate, the on-disk paths banger writes, network mutations, and how install/uninstall work end to end. The aim is to give a reader enough information to grant or refuse the privileges banger asks for during system install with their eyes open. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/privileges.md | 250 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/privileges.md diff --git a/docs/privileges.md b/docs/privileges.md new file mode 100644 index 0000000..89b31f1 --- /dev/null +++ b/docs/privileges.md @@ -0,0 +1,250 @@ +# Privileges + +This document describes exactly what banger does with the privileges it +asks for, what runs where, and how to undo it. The aim is to give a +reader enough information to grant — or refuse — the privileges with +their eyes open. + +## Two services, two trust boundaries + +`banger system install` lays down two systemd units: + +| Unit | User | Socket | Purpose | +|---|---|---|---| +| `bangerd.service` | owner user (chosen at install) | `/run/banger/bangerd.sock` (0700, owner) | Orchestration: VM/image lifecycle, store, RPC to the CLI. | +| `bangerd-root.service` | `root` | `/run/banger-root/bangerd-root.sock` (0600, root) | Narrow root helper: bridge/tap, DM snapshots, NAT, Firecracker launch. | + +The owner daemon does all the business logic. It never runs as root. +The root helper runs as root but only accepts a fixed list of operations +and rejects every input that isn't a banger-managed path or name. + +The CLI (`banger ...`) talks to the owner daemon. The owner daemon +talks to the root helper for the handful of things only root can do. +Users and CI scripts never call the root helper directly. + +### Why two daemons + +Before this split the owner daemon shelled `sudo` for every device or +network operation. That meant the user's `sudo` config gated daily +work, and an attacker who compromised the owner daemon inherited +arbitrary `sudo` reach. After the split, the owner daemon has no +ambient root. The only way for it to make a privileged change is to +ask the helper, and the helper only honours requests that fit a +specific shape. + +## Authentication + +The root helper: + +- Listens on a Unix socket at `/run/banger-root/bangerd-root.sock`, + mode 0600, owned by root, in a runtime dir at 0711 root. +- Reads `SO_PEERCRED` on every accepted connection and rejects any + caller whose UID is not 0 or the owner UID recorded in + `/etc/banger/install.toml`. The match is by UID, not username. +- Decodes one JSON request per connection and dispatches it through a + named-method switch. Unknown methods return `unknown_method`. + +The owner daemon: + +- Listens on `/run/banger/bangerd.sock`, mode 0700, owned by the + install-time owner user. Other host users cannot connect. +- Resolves the helper socket path from the install metadata and + retries with backoff if the helper hasn't started yet. + +There is no network listener. Every banger control surface is a Unix +socket on the local host. + +## What the root helper will do, exactly + +The helper exposes 17 RPC methods. Each is shaped so the owner daemon +can name a banger-managed object but cannot pass an arbitrary host +path or interface name. Code lives in +`internal/roothelper/roothelper.go`. + +| Method | Effect | Validation gate | +|---|---|---| +| `priv.ensure_bridge` | Create the configured Linux bridge if missing; assign the bridge IP. | Bridge name and IP come from owner config; helper does not allow caller to pick `lo` etc. | +| `priv.create_tap` | `ip link add tap NAME tuntap` and add to bridge, owned by the owner user. | Tap name must match `tap-fc-*` or `tap-pool-*`. | +| `priv.delete_tap` | `ip link del NAME`. | Same prefix check. | +| `priv.sync_resolver_routing` | `resolvectl dns/domain/default-route` on the configured bridge. | No-op if `resolvectl` is missing. Bridge name comes from owner config. | +| `priv.clear_resolver_routing` | `resolvectl revert` on the bridge. | Same. | +| `priv.ensure_nat` | `iptables -t nat MASQUERADE` for `(guest_ip, tap)` plus matching FORWARD rules; `enable=false` removes them. | Tap and IP come from VM record; helper does not run arbitrary iptables. | +| `priv.create_dm_snapshot` | Create a `dmsetup` device-mapper snapshot from `rootfs.ext4` with COW backing file. | Both paths must be inside `/var/lib/banger`; DM name must start with `fc-rootfs-`. | +| `priv.cleanup_dm_snapshot` | `dmsetup remove` for a snapshot the helper itself just created. | Acts on the typed `dmsnap.Handles` returned by create. | +| `priv.remove_dm_snapshot` | `dmsetup remove` by target name. | Name must start with `fc-rootfs-`. | +| `priv.fsck_snapshot` | `e2fsck -fy` against the DM device. | Tolerates exit 1 (filesystem cleaned). | +| `priv.read_ext4_file` | Read a file from inside an ext4 image via `debugfs cat`. | Path is inside the image; image path is not validated against the state dir today (the helper trusts the daemon for image paths because images can sit anywhere the owner registers). | +| `priv.write_ext4_files` | Batch write files into an ext4 image, root:root, mode-controlled. | Same. | +| `priv.resolve_firecracker_binary` | Stat and return the firecracker binary path. | Resolved path must be a regular file, executable, root-owned, not group/world-writable. | +| `priv.launch_firecracker` | Start the firecracker process for a VM. | Socket and vsock paths must be inside `/run/banger`. Log/metrics/kernel paths must be inside `/var/lib/banger`. Tap name must be banger-prefixed. Drives must be inside the state dir or be a `/dev/mapper/fc-rootfs-*` device. Binary must pass the same root-owned-executable check. | +| `priv.ensure_socket_access` | `chown` and `chmod 0660` on a firecracker API or vsock socket so the owner user can talk to it. | Helper does not chown arbitrary paths; this is invoked only after the helper itself just created the socket via firecracker. | +| `priv.find_firecracker_pid` / `priv.kill_process` / `priv.signal_process` / `priv.process_running` | Look up a firecracker PID by API socket path; signal or stat the resulting process. | Fixed-shape requests; path validation happens at launch time, and PID lookups are filtered to processes whose cmdline mentions the requested API socket. | + +Anything outside this list returns `unknown_method` and is logged. The +helper does not run a shell, does not exec helper scripts, and does +not accept commands as strings. + +## Filesystem mutations + +Path used | Owner | What is created or changed +---|---|--- +`/etc/banger/install.toml` | root, 0644 | Written once by `banger system install`. Holds owner UID/GID/home, install timestamp, version. Read by both daemons at startup. +`/etc/systemd/system/bangerd.service` | root, 0644 | Owner-daemon unit. Contents are deterministic; see below. +`/etc/systemd/system/bangerd-root.service` | root, 0644 | Root-helper unit. +`/usr/local/bin/banger` | root, 0755 | Copy of the build output. +`/usr/local/bin/bangerd` | root, 0755 | Same binary, second name. +`/usr/local/lib/banger/banger-vsock-agent` | root, 0755 | Companion agent injected into guests at image-pull time. +`/var/lib/banger/...` | owner (via systemd `StateDirectory=banger`), 0700 | Image artifacts, VM dirs, work disks, kernels, OCI cache, SSH key + known_hosts. +`/var/cache/banger/...` | owner, 0700 | Bundle and OCI download cache. +`/run/banger/...` | owner, 0700 | Owner daemon socket and per-VM firecracker API + vsock sockets. +`/run/banger-root/...` | root, 0711 | Root-helper socket dir; the socket itself is 0600. +`~/.config/banger/banger.toml` | owner | Optional user config. Read by the owner daemon at startup. + +Outside these directories, banger does not write to the host filesystem +during normal operation. The two exceptions are file-sync (the user +explicitly opts in to copying paths from their home into a guest, which +the owner daemon validates is inside the owner home before reading) +and the install/uninstall actions above. + +### Why the owner home is locked down + +The `[[file_sync]]` config lets users mirror host files into guests. +banger refuses to follow paths that escape the owner home, including +through symlinks: + +- `ResolveFileSyncHostPath` (`internal/config/config.go`) expands a + leading `~/` and rejects any candidate that resolves outside the + configured `OwnerHomeDir`. +- `ResolveExistingFileSyncHostPath` re-checks after `EvalSymlinks` so + a symlink inside `~/.aws` that points at `/etc/shadow` cannot leak + out. + +This means an installed banger never reads outside the owner home in +the file-sync path, even if the owner edits config to try. + +## Network mutations + +For each running VM banger creates: + +- One bridge (default `banger0`, configurable). Created on first VM + start, never deleted automatically. +- One tap interface named `tap-fc-`. Created on VM start, + deleted on VM stop or crash recovery. +- One iptables MASQUERADE rule per VM, only when `--nat` was passed. + Removed by the symmetric `EnsureNAT(enable=false)` call at stop. +- Optionally, `resolvectl` routing entries that send `*.vm` lookups to + banger's in-process DNS server on the bridge. Reverted at stop. + +Banger does not touch UFW, firewalld, or other rule managers. It only +edits the iptables tables it created the rules in. + +## Cleanup and uninstall + +Per-VM cleanup happens at: + +- `banger vm stop ` — stops firecracker, removes the per-VM tap, + drops the NAT rule, removes the DM snapshot, removes per-VM + sockets, leaves the work disk. +- `banger vm delete ` — same as stop, plus deletes the per-VM + state directory under `/var/lib/banger/vms/` (work disk, + metadata). +- `banger vm prune` — bulk version. +- Crash recovery: on daemon start, `reconcile` runs the same teardown + for any VM whose firecracker process is no longer alive. + +System-level uninstall: + +``` +sudo banger system uninstall # remove services, units, binaries +sudo banger system uninstall --purge # also remove /var/lib/banger, + # /var/cache/banger, /run/banger +``` + +Without `--purge`, the state dirs survive so a reinstall can pick up +where the previous one left off. With `--purge`, banger leaves no +files behind under `/var/lib`, `/var/cache`, or `/run`. + +What `uninstall` does, in order: + +1. `systemctl disable --now bangerd.service bangerd-root.service`. +2. Remove `/etc/systemd/system/bangerd.service` and `bangerd-root.service`. +3. Remove `/etc/banger/install.toml` and `/etc/banger/`. +4. `systemctl daemon-reload`. +5. Remove `/usr/local/bin/banger`, `/usr/local/bin/bangerd`, + `/usr/local/lib/banger/`. +6. With `--purge` only: remove the system state, cache, and runtime + dirs. + +What `uninstall` does NOT do automatically: + +- It does not delete the bridge or any iptables rules. Stop your VMs + first (`banger vm stop --all`) so the per-VM teardown drops them. + The bridge itself is intentionally persistent — a future reinstall + reuses it. To remove it manually: `sudo ip link del banger0`. +- It does not undo `resolvectl` routing on a bridge that no longer + exists; the entries are harmless if the bridge is gone. +- It does not remove the owner user, the owner's home, or anything + the user wrote into a guest from inside the guest. + +## Hardening of the systemd units + +The two units ship with restrictive defaults; they are written by +banger at install time and the contents are deterministic. + +Owner daemon (`bangerd.service`): + +- `User=` is the install-time owner; never `root`. +- `NoNewPrivileges=yes`. +- `ProtectSystem=strict` — system directories are read-only. +- `ProtectHome=read-only` — owner home is read-only to the daemon + unit. The daemon writes only to `StateDirectory`, `CacheDirectory`, + `RuntimeDirectory`, plus owner config that the user edits. +- `ProtectControlGroups`, `ProtectKernelLogs`, `ProtectKernelModules`, + `ProtectClock`, `ProtectHostname`, `RestrictSUIDSGID`, + `LockPersonality`. +- `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_VSOCK`. +- No `AmbientCapabilities`. + +Root helper (`bangerd-root.service`): + +- Same hardening as above, plus `ProtectHome=yes` (no host-home + visibility at all from the helper). +- `CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_RAW CAP_SYS_ADMIN`. + Only the capabilities required for tap/bridge, iptables, dmsetup, + loop devices, and Firecracker. No `CAP_SYS_BOOT`, no `CAP_SYS_PTRACE`, + no `CAP_SYS_MODULE`, no `CAP_NET_BIND_SERVICE`. +- `ReadWritePaths=/var/lib/banger`. + +## What this leaves you trusting + +If you install banger as root, you are trusting: + +1. The two binaries banger drops under `/usr/local/bin` and the + companion agent under `/usr/local/lib/banger`. These should match + the build artifacts you reviewed. +2. The path validators in + `internal/roothelper/roothelper.go:validateManagedPath`, + `validateTapName`, `validateDMName`, and `validateRootExecutable` + to be tight. If those are bypassed, the helper would carry out a + privileged op against an unmanaged path. They are unit-tested in + `internal/roothelper/roothelper_test.go`. +3. The Firecracker binary banger executes. The helper refuses to launch + anything that isn't a regular, executable, root-owned, not + world-writable file — but the binary's own behaviour is your + responsibility. +4. Your own owner-user account. The owner can ask the helper to + create taps, run firecracker, and edit ext4 images under + `/var/lib/banger`. Anyone with the owner's UID can do those + things; treat that account as semi-privileged. + +What you do **not** have to trust: + +- The CLI process. It only talks Unix-socket RPC. +- Other host users. The helper socket is 0600 root and the owner + socket is 0700 owner. +- The contents of the user's home, except the file paths that + `[[file_sync]]` explicitly names — and even those are clamped to + the owner home. +- The guest. Guests cannot reach the helper or the owner daemon; the + only host endpoint a guest sees is the in-process DNS server on the + bridge IP and the bridge itself for outbound NAT.