The filesystem-mutations table referred to `~/.config/banger/banger.toml`, but the daemon reads `~/.config/banger/config.toml` (per internal/config/config.go and README.md). Bring privileges.md in line. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
16 KiB
Markdown
298 lines
16 KiB
Markdown
# 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` (0600, owner) | Orchestration: VM/image lifecycle, store, RPC to the CLI. |
|
||
| `bangerd-root.service` | `root` | `/run/banger-root/bangerd-root.sock` (0600, owner; root-owned dir at 0711) | 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 the registered owner UID, in a root-owned
|
||
runtime dir at 0711.
|
||
- 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 0600, owned by the
|
||
install-time owner user. Other host users cannot connect.
|
||
- Reads `SO_PEERCRED` on every accepted connection and rejects any
|
||
caller whose UID is not 0 or the install-time owner UID. The
|
||
filesystem perms already gate access; the peer-cred read is
|
||
belt-and-braces in case the socket FD is ever leaked to a
|
||
non-owner process.
|
||
- 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 a fixed list of RPC methods (see
|
||
`internal/roothelper/roothelper.go` for the canonical set). Each is
|
||
shaped so the owner daemon can name a banger-managed object but
|
||
cannot pass an arbitrary host path or interface name. Every input
|
||
that names a path, device, PID, or interface is checked against a
|
||
validator before the helper touches the host.
|
||
|
||
| 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. | Bridge name passes the kernel iface-name rules (1–15 chars, no `/`/`:`/whitespace, not `.`/`..`). Resolver address must parse via `net.ParseIP`. |
|
||
| `priv.clear_resolver_routing` | `resolvectl revert` on the bridge. | Same iface-name check. |
|
||
| `priv.ensure_nat` | `iptables -t nat MASQUERADE` for `(guest_ip, tap)` plus matching FORWARD rules; `enable=false` removes them. | Tap must be banger-prefixed. Guest IP must parse as IPv4. |
|
||
| `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` and `losetup -d` for a snapshot the helper itself just created. | Every non-empty `dmsnap.Handles` field is checked: DM name `fc-rootfs-*`, DM device `/dev/mapper/fc-rootfs-*`, loops `/dev/loopN`. |
|
||
| `priv.remove_dm_snapshot` | `dmsetup remove` by target. | Target must be either a `fc-rootfs-*` name or a `/dev/mapper/fc-rootfs-*` path. |
|
||
| `priv.fsck_snapshot` | `e2fsck -fy` against the DM device. | DM device path must match `/dev/mapper/fc-rootfs-*`. Exit 1 (filesystem cleaned) is tolerated. |
|
||
| `priv.read_ext4_file` | Read a file from inside an ext4 image via `debugfs cat`. | Image path must be inside `/var/lib/banger` or a managed DM device. Guest path is rejected if it contains debugfs-hostile chars (`"`/`\`/newline). |
|
||
| `priv.write_ext4_files` | Batch write files into an ext4 image, root:root, mode-controlled. | Same image-path validator. |
|
||
| `priv.resolve_firecracker_binary` | Stat and return the firecracker binary path. | Path is opened with `O_PATH \| O_NOFOLLOW` (refusing symlinks) and Fstat'd through the resulting fd: must be a regular file, executable, root-owned, not group/world-writable. |
|
||
| `priv.launch_firecracker` | Start the firecracker process for a VM (jailer-wrapped). | Socket and vsock paths must be inside `/run/banger`. Log/metrics/kernel/initrd 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. Jailer chroot base must be inside the system state/runtime dirs; jailer UID/GID must equal the registered owner. Binary must pass the same root-owned-executable check. |
|
||
| `priv.ensure_socket_access` | `chown` and `chmod 0600` on a firecracker API or vsock socket so the owner user can talk to it. | Path must be inside `/run/banger` and not a symlink. The helper opens it with `O_PATH \| O_NOFOLLOW`, refuses anything that isn't a unix socket, and chmod/chown via the resulting fd (no symlink-follow). The local-priv fallback uses `chown -h`. |
|
||
| `priv.cleanup_jailer_chroot` | Detach every mount under the per-VM jailer chroot via direct `umount2(MNT_DETACH \| UMOUNT_NOFOLLOW)` syscalls (deepest-first), then `rm -rf` the tree. | Path must be inside the system state/runtime dirs and not a symlink — including no symlinks at intermediate components (resolved with `EvalSymlinks` and re-checked). `UMOUNT_NOFOLLOW` makes the unmounts symlink-safe even if a path is swapped after validation. A `findmnt` guard refuses to `rm -rf` if any mount remains underneath. |
|
||
| `priv.find_firecracker_pid` | Resolve a firecracker PID by API socket path. | Filters to processes whose cmdline mentions the requested API socket. |
|
||
| `priv.kill_process` / `priv.signal_process` | Send SIGKILL or a named signal to a PID. | PID must refer to a running process whose `/proc/<pid>/cmdline` mentions `firecracker`. |
|
||
| `priv.process_running` | Check whether a PID is alive (no host mutation). | Read-only; same cmdline filter. |
|
||
|
||
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/config.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-<vm_id>`. 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 <name>` — 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 <name>` — same as stop, plus deletes the per-VM
|
||
state directory under `/var/lib/banger/vms/<id>` (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.
|
||
|
||
## Running outside the system install
|
||
|
||
Everything above describes the supported deployment: `banger system
|
||
install` lays down both systemd units and the helper takes over every
|
||
privileged operation.
|
||
|
||
It is also possible to run `bangerd` directly without installing the
|
||
helper — the binary still works as a per-user daemon and shells `sudo
|
||
-n` for each privileged operation it would otherwise hand off
|
||
(`iptables`, `ip`, `mount`, `mknod`, `dmsetup`, `e2fsck`, `kill`,
|
||
`chown -h`, `chmod`, `losetup`, `chown`, `chmod`, `firecracker`).
|
||
This mode is intended for ad-hoc developer machines while iterating on
|
||
banger itself.
|
||
|
||
It carries a different trust model:
|
||
|
||
- It needs `NOPASSWD` sudoers entries for the developer (otherwise
|
||
every VM action prompts for a password).
|
||
- Once those entries exist, **any** process running as the developer
|
||
can invoke those commands with arbitrary arguments — banger's input
|
||
validators only constrain what banger itself sends. They are no
|
||
defence against a different program on the same account.
|
||
- The helper's `SO_PEERCRED` boundary, the systemd hardening
|
||
(`NoNewPrivileges`, `ProtectSystem=strict`, the narrow
|
||
`CapabilityBoundingSet`), and the helper's own input validators are
|
||
all bypassed.
|
||
|
||
If you care about isolating banger's blast radius from anything else
|
||
running as your user, use the system install. If you only need
|
||
banger to work on your own dev box, the non-system mode is fine —
|
||
just don't run it on a shared or production host.
|
||
|
||
## 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/identifier validators in
|
||
`internal/roothelper/roothelper.go` to be tight: `validateManagedPath`,
|
||
`validateTapName`, `validateDMName`, `validateDMDevicePath`,
|
||
`validateLoopDevicePath`, `validateDMRemoveTarget`,
|
||
`validateDMSnapshotHandles`, `validateRootExecutable`,
|
||
`validateNotSymlink`, `validateExt4ImagePath`,
|
||
`validateLinuxIfaceName`, `validateIPv4`, `validateResolverAddr`,
|
||
and `validateFirecrackerPID`. If any of these are bypassed, the
|
||
helper would carry out a privileged op against an unmanaged
|
||
target. 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.
|