From caf1da580ac024d39e95ec98450e1a5960a39a04 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 12 Jul 2026 16:17:37 -0400 Subject: [PATCH] feat(firecracker): group-owned TAP pool for multi-user hosts Add a `group` option to the netpool NixOS module + BOT_BOTTLE_FC_GROUP to the shell script: when set, the pool's TAP devices are owned by a group instead of a single user, so any group member can open them (the kernel lets a TAP's owning-group members attach). This lets an interactive user and, say, a CI-runner user share one pool. `owner`/`group` are mutually exclusive (asserted). Single-user `owner` remains the default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- nix/firecracker-netpool.nix | 33 +++++++++++++++++++++++--- scripts/firecracker-netpool.sh | 18 +++++++++++--- tests/unit/test_firecracker_backend.py | 2 ++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/nix/firecracker-netpool.nix b/nix/firecracker-netpool.nix index 0357bda..699e775 100644 --- a/nix/firecracker-netpool.nix +++ b/nix/firecracker-netpool.nix @@ -77,10 +77,16 @@ let } ''; + # A non-owner user can't open a user-owned TAP, but any member of a + # TAP's owning GROUP can (the kernel checks owner-uid OR owning-group). + # So `group` lets an interactive user and a CI-runner user share one + # pool; `owner` is the single-user default. + tapOwn = if cfg.group != null then "group ${cfg.group}" else "user ${cfg.owner}"; + upScript = pkgs.writeShellScript "bot-bottle-fc-netpool-up" '' set -eu ${lib.concatMapStringsSep "\n" (s: '' - ${ip} link show ${s.iface} >/dev/null 2>&1 || ${ip} tuntap add dev ${s.iface} mode tap user ${cfg.owner} + ${ip} link show ${s.iface} >/dev/null 2>&1 || ${ip} tuntap add dev ${s.iface} mode tap ${tapOwn} ${ip} addr replace ${s.hostIp}/31 dev ${s.iface} ${ip} link set ${s.iface} up '') slots} @@ -123,9 +129,26 @@ in }; owner = lib.mkOption { - type = lib.types.str; + type = lib.types.nullOr lib.types.str; + default = null; example = "alice"; - description = "User that owns the TAP devices, so `./cli.py start` opens them without root."; + description = '' + User that owns the TAP devices (single-user hosts), so + `./cli.py start` opens them without root. Set exactly one of + `owner` or `group`. + ''; + }; + + group = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "firecracker"; + description = '' + Group that owns the TAP devices instead of a single user. Any + member of this group can open the pool, so an interactive user + and a CI-runner user can share one pool. Set exactly one of + `owner` or `group`. + ''; }; tableName = lib.mkOption { @@ -151,6 +174,10 @@ in assertion = lib.mod baseInt 2 == 0; message = "services.bot-bottle-firecracker.ipBase must be /31-aligned (even final octet); got ${cfg.ipBase}."; } + { + assertion = (cfg.owner != null) != (cfg.group != null); + message = "services.bot-bottle-firecracker: set exactly one of `owner` or `group`."; + } ]; # VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on. diff --git a/scripts/firecracker-netpool.sh b/scripts/firecracker-netpool.sh index 14681c9..d08fcde 100755 --- a/scripts/firecracker-netpool.sh +++ b/scripts/firecracker-netpool.sh @@ -38,6 +38,10 @@ # BOT_BOTTLE_FC_IP_BASE base IPv4 of the /31 pool (default 10.243.0.0) # BOT_BOTTLE_FC_IFACE_PREFIX TAP name prefix (default bbfc) # BOT_BOTTLE_FC_OWNER owning user (default $SUDO_USER or $USER) +# BOT_BOTTLE_FC_GROUP owning group; if set, TAPs are group-owned +# instead of user-owned, so any group member +# (e.g. an interactive user + a CI runner +# user) can open the pool. Overrides OWNER. set -euo pipefail @@ -45,6 +49,7 @@ POOL_SIZE="${BOT_BOTTLE_FC_POOL_SIZE:-8}" IP_BASE="${BOT_BOTTLE_FC_IP_BASE:-10.243.0.0}" PREFIX="${BOT_BOTTLE_FC_IFACE_PREFIX:-bbfc}" OWNER="${BOT_BOTTLE_FC_OWNER:-${SUDO_USER:-$USER}}" +GROUP="${BOT_BOTTLE_FC_GROUP:-}" TABLE="bot_bottle_fc" # Sidecar ports (must match the backend). egress=9099, supervise=9100, @@ -73,7 +78,14 @@ require_root() { cmd_up() { require_root up - echo "firecracker net pool: $POOL_SIZE slots, base $IP_BASE, owner $OWNER" + # Group ownership (any member can open the pool) overrides single-user + # ownership. The kernel lets a TAP's owning-group members attach. + if [ -n "$GROUP" ]; then + own_args=(group "$GROUP") ; own_desc="group=$GROUP" + else + own_args=(user "$OWNER") ; own_desc="owner=$OWNER" + fi + echo "firecracker net pool: $POOL_SIZE slots, base $IP_BASE, $own_desc" # VM->sidecar traffic is DNAT'd to the sidecar container and # forwarded, so forwarding must be enabled (Docker also sets this). @@ -86,10 +98,10 @@ cmd_up() { ip link set "$dev" down 2>/dev/null || true ip tuntap del dev "$dev" mode tap 2>/dev/null || true fi - ip tuntap add dev "$dev" mode tap user "$OWNER" + ip tuntap add dev "$dev" mode tap "${own_args[@]}" ip addr add "$host/31" dev "$dev" ip link set "$dev" up - echo " $dev host=$host guest=$(guest_ip "$i") owner=$OWNER" + echo " $dev host=$host guest=$(guest_ip "$i") $own_desc" done _install_nft diff --git a/tests/unit/test_firecracker_backend.py b/tests/unit/test_firecracker_backend.py index 6d0fed5..737835d 100644 --- a/tests/unit/test_firecracker_backend.py +++ b/tests/unit/test_firecracker_backend.py @@ -69,6 +69,8 @@ class TestNetpoolRenderers(unittest.TestCase): self.assertIn("bot-bottle-firecracker-netpool", mod) # the oneshot self.assertIn(netpool.NFT_TABLE, mod) self.assertIn('iifname != "${cfg.ifacePrefix}*" return', mod) + # Supports group-owned TAPs (shared pool for a multi-user host). + self.assertIn("group ${cfg.group}", mod) def test_shell_setup_reflects_overrides(self): with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}):