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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-12 16:17:37 -04:00
parent c44a1ffdc1
commit caf1da580a
3 changed files with 47 additions and 6 deletions
+30 -3
View File
@@ -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.
+15 -3
View File
@@ -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
+2
View File
@@ -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"}):