refactor(firecracker): non-invasive NixOS module (no firewall switch)

The module used `networking.nftables.tables.*` (which forces
`networking.nftables.enable = true`, flipping the host firewall backend)
and `systemd.network.enable` (handing interfaces to systemd-networkd) —
disruptive on an iptables + Docker daily driver.

Rewrite it to a single systemd oneshot that brings the pool up: idempotent
`ip tuntap` for the TAPs + `nft -f` for the independent `inet bot_bottle_fc`
table (its own hooks at priority -10), with ExecStop teardown. Same as the
imperative script, but declarative. It touches neither the firewall backend
nor networkd, so it coexists with iptables/Docker/ufw/firewalld. Verified
through the NixOS module system (service present, firewall untouched).

Drop the now-redundant `render_nixos_module()` paste generator (the module
is a real importable file) and point `backend setup` at importing it (flake
output or the file path), noting it's non-invasive.

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-11 17:25:50 -04:00
parent 949b001464
commit 480269b116
4 changed files with 98 additions and 125 deletions
+6 -68
View File
@@ -6,8 +6,9 @@ a pool of point-to-point TAP devices (owned by the invoking user, so
`./cli.py start` never needs root) and a dedicated nftables table that `./cli.py start` never needs root) and a dedicated nftables table that
isolates every VM. This module is the single source of truth for the isolates every VM. This module is the single source of truth for the
pool parameters — the shell script (`scripts/firecracker-netpool.sh`), pool parameters — the shell script (`scripts/firecracker-netpool.sh`),
the printed NixOS snippet, and the backend's fail-closed preflight all the NixOS module (`nix/firecracker-netpool.nix`), and the backend's
derive from the constants here so they can't drift. fail-closed preflight all derive from these constants so they can't
drift.
Topology (per slot i): Topology (per slot i):
* TAP ``bbfc{i}`` — no shared bridge, so no docker0 / virbr0 / cni0 * TAP ``bbfc{i}`` — no shared bridge, so no docker0 / virbr0 / cni0
@@ -237,72 +238,9 @@ def render_shell_setup() -> str:
return f"sudo {prefix}./scripts/firecracker-netpool.sh up" return f"sudo {prefix}./scripts/firecracker-netpool.sh up"
def render_nixos_module() -> str: # The NixOS setup is a real, importable module (nix/firecracker-netpool.nix,
"""A paste-ready NixOS snippet, derived from the live constants. # exposed as the flake output nixosModules.firecracker-netpool) rather than a
# generated paste — see `backend setup` output.
Uses systemd-networkd tap netdevs (so TAPs are only reconfigured
when this config changes, not torn down on every rebuild — which
would drop running VMs) and `networking.nftables.tables.<name>`
(an independent table that coexists with Docker's iptables rules
rather than replacing the whole ruleset)."""
slots = all_slots()
owner = os.environ.get("USER", "youruser")
netdevs = "\n".join(
f''' "10-{s.iface}" = {{
netdevConfig = {{ Name = "{s.iface}"; Kind = "tap"; }};
tapConfig = {{ User = "{owner}"; }};
}};'''
for s in slots
)
networks = "\n".join(
f''' "10-{s.iface}" = {{
matchConfig.Name = "{s.iface}";
address = [ "{s.host_ip}/31" ];
networkConfig.ConfigureWithoutCarrier = true;
}};'''
for s in slots
)
return f"""# bot-bottle Firecracker network pool ({len(slots)} slots, base {ip_base()}).
# Generated by `./cli.py backend setup --backend=firecracker`; owner user = {owner!r}.
{{ ... }}:
{{
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
systemd.network.enable = true;
systemd.network.netdevs = {{
{netdevs}
}};
systemd.network.networks = {{
{networks}
}};
# Independent table — does not touch Docker/ufw/firewalld rules.
# A bottle VM (traffic from a bbfc* TAP) may reach only its own
# sidecar (DNAT'd from the host TAP IP); everything else is dropped,
# so there is no egress except through the sidecar proxy.
networking.nftables.enable = true;
networking.nftables.tables."{NFT_TABLE}" = {{
family = "inet";
content = ''
chain forward {{
type filter hook forward priority -10; policy accept;
iifname != "{IFACE_PREFIX}*" return
ct state established,related accept
ct status dnat accept
drop
}}
chain input {{
type filter hook input priority -10; policy accept;
iifname != "{IFACE_PREFIX}*" return
ct state established,related accept
drop
}}
'';
}};
}}
"""
def _nondefault_env() -> str: def _nondefault_env() -> str:
+13 -8
View File
@@ -29,6 +29,11 @@ def _owner() -> str:
return os.environ.get("USER", "youruser") return os.environ.get("USER", "youruser")
def _module_path() -> str:
"""Absolute path to the importable NixOS module in this checkout."""
return str(Path(__file__).resolve().parents[3] / "nix" / "firecracker-netpool.nix")
def _print_prereqs() -> None: def _print_prereqs() -> None:
"""The firecracker binary + KVM + guest artifacts, shown before the """The firecracker binary + KVM + guest artifacts, shown before the
privileged network-pool step so operators see the full picture.""" privileged network-pool step so operators see the full picture."""
@@ -96,18 +101,18 @@ def setup() -> int:
_warn_overlaps() _warn_overlaps()
if _is_nixos(): if _is_nixos():
sys.stderr.write( sys.stderr.write(
"Detected NixOS. Preferred: consume the flake module (versioned, " "Detected NixOS. Import the module — it is NON-INVASIVE: it does "
"no copy-paste drift):\n\n" "not flip networking.nftables.enable or systemd.network.enable, so "
" # flake inputs (point at wherever you host bot-bottle):\n" "your existing (iptables) firewall and Docker are untouched. A "
"systemd oneshot brings the pool up alongside them.\n\n"
" # flake users:\n"
" inputs.bot-bottle.url = \"git+ssh://<your-bot-bottle-remote>\";\n" " inputs.bot-bottle.url = \"git+ssh://<your-bot-bottle-remote>\";\n"
" # host module:\n"
" imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ];\n" " imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ];\n"
" # channel (non-flake) users — import the file directly:\n"
f" imports = [ {_module_path()} ];\n\n"
f" services.bot-bottle-firecracker = {{ enable = true; owner = \"{_owner()}\"; }};\n\n" f" services.bot-bottle-firecracker = {{ enable = true; owner = \"{_owner()}\"; }};\n\n"
"Then `nixos-rebuild switch`. Channel (non-flake) users can " "Then `nixos-rebuild switch`.\n"
"`imports = [ <bot-bottle>/nix/firecracker-netpool.nix ];` instead.\n\n"
"Fallback — paste this generated module directly:\n\n"
) )
sys.stdout.write(netpool.render_nixos_module())
else: else:
sys.stderr.write("Run the one-time setup as root:\n\n") sys.stderr.write("Run the one-time setup as root:\n\n")
sys.stdout.write(netpool.render_shell_setup() + "\n") sys.stdout.write(netpool.render_shell_setup() + "\n")
+68 -39
View File
@@ -2,10 +2,20 @@
# #
# The one-time privileged setup the Firecracker backend needs: a pool of # The one-time privileged setup the Firecracker backend needs: a pool of
# user-owned point-to-point TAP devices plus a fail-closed nftables table # user-owned point-to-point TAP devices plus a fail-closed nftables table
# that confines every microVM to its own sidecar. Enabling this module is # that confines every microVM to its own sidecar.
# the NixOS equivalent of `sudo ./scripts/firecracker-netpool.sh up` — but #
# declarative, so the taps/table survive `nixos-rebuild` and are only # NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable`
# reconfigured when this config changes (running VMs aren't dropped). # (which would switch your whole host firewall backend) or
# `systemd.network.enable` (which would hand your interfaces to
# systemd-networkd). Instead a single systemd oneshot service brings the
# pool up on boot — the declarative equivalent of
# `sudo ./scripts/firecracker-netpool.sh up`. The `inet <tableName>` table
# is independent (its own hooks at priority -10), so it coexists with an
# iptables `networking.firewall`, Docker, ufw, firewalld, etc.
#
# The oneshot is only restarted when this config changes, so a
# `nixos-rebuild switch` doesn't tear down TAPs out from under running
# VMs unless you actually changed the pool.
# #
# The option defaults MUST match the backend constants in # The option defaults MUST match the backend constants in
# bot_bottle/backend/firecracker/netpool.py (pool size, IP base, iface # bot_bottle/backend/firecracker/netpool.py (pool size, IP base, iface
@@ -13,7 +23,7 @@
# the BOT_BOTTLE_FC_* env vars; if you override an option here, override # the BOT_BOTTLE_FC_* env vars; if you override an option here, override
# the matching env var for the CLI too (or set writeEnvFile = true below # the matching env var for the CLI too (or set writeEnvFile = true below
# to have this module emit them). Keep the two sides in lockstep. # to have this module emit them). Keep the two sides in lockstep.
{ config, lib, ... }: { config, lib, pkgs, ... }:
let let
cfg = config.services.bot-bottle-firecracker; cfg = config.services.bot-bottle-firecracker;
@@ -42,7 +52,47 @@ let
hostIp = intToIp (baseInt + 2 * i); hostIp = intToIp (baseInt + 2 * i);
}) cfg.poolSize; }) cfg.poolSize;
mkAttr = f: lib.listToAttrs (map (s: lib.nameValuePair "10-${s.iface}" (f s)) slots); ip = "${pkgs.iproute2}/bin/ip";
nft = "${pkgs.nftables}/bin/nft";
# Idempotent ruleset: (create-if-absent → delete → recreate) is atomic
# within one `nft -f`, so re-running `up` always lands a clean table.
nftFile = pkgs.writeText "bot-bottle-fc.nft" ''
table inet ${cfg.tableName}
delete table inet ${cfg.tableName}
table inet ${cfg.tableName} {
chain forward {
type filter hook forward priority -10; policy accept;
iifname != "${cfg.ifacePrefix}*" return
ct state established,related accept
ct status dnat accept
drop
}
chain input {
type filter hook input priority -10; policy accept;
iifname != "${cfg.ifacePrefix}*" return
ct state established,related accept
drop
}
}
'';
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} addr replace ${s.hostIp}/31 dev ${s.iface}
${ip} link set ${s.iface} up
'') slots}
${nft} -f ${nftFile}
'';
downScript = pkgs.writeShellScript "bot-bottle-fc-netpool-down" ''
${nft} delete table inet ${cfg.tableName} 2>/dev/null || true
${lib.concatMapStringsSep "\n" (s: ''
${ip} link show ${s.iface} >/dev/null 2>&1 && ${ip} tuntap del dev ${s.iface} mode tap || true
'') slots}
'';
in in
{ {
options.services.bot-bottle-firecracker = { options.services.bot-bottle-firecracker = {
@@ -106,39 +156,18 @@ in
# VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on. # VM->sidecar traffic is DNAT'd and forwarded, so forwarding must be on.
boot.kernel.sysctl."net.ipv4.ip_forward" = 1; boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
systemd.network.enable = true; # One oneshot brings up the whole pool (TAPs + independent nft table).
systemd.network.netdevs = mkAttr (s: { # No networking.nftables.enable / systemd.network.enable — see header.
netdevConfig = { Name = s.iface; Kind = "tap"; }; systemd.services."bot-bottle-firecracker-netpool" = {
tapConfig = { User = cfg.owner; }; description = "bot-bottle Firecracker TAP pool + nft isolation table";
}); wantedBy = [ "multi-user.target" ];
systemd.network.networks = mkAttr (s: { after = [ "network-pre.target" ];
matchConfig.Name = s.iface; serviceConfig = {
address = [ "${s.hostIp}/31" ]; Type = "oneshot";
networkConfig.ConfigureWithoutCarrier = true; RemainAfterExit = true;
}); ExecStart = upScript;
ExecStop = downScript;
# Independent, fail-closed table — coexists with Docker/ufw/firewalld };
# rather than replacing the ruleset. A VM (traffic from a
# ${cfg.ifacePrefix}* TAP) may reach only its own sidecar (DNAT'd from
# the host TAP IP); everything else is dropped.
networking.nftables.enable = true;
networking.nftables.tables.${cfg.tableName} = {
family = "inet";
content = ''
chain forward {
type filter hook forward priority -10; policy accept;
iifname != "${cfg.ifacePrefix}*" return
ct state established,related accept
ct status dnat accept
drop
}
chain input {
type filter hook input priority -10; policy accept;
iifname != "${cfg.ifacePrefix}*" return
ct state established,related accept
drop
}
'';
}; };
environment.etc."bot-bottle/firecracker.env" = lib.mkIf cfg.writeEnvFile { environment.etc."bot-bottle/firecracker.env" = lib.mkIf cfg.writeEnvFile {
+11 -10
View File
@@ -55,16 +55,17 @@ class TestNetpoolSlots(unittest.TestCase):
class TestNetpoolRenderers(unittest.TestCase): class TestNetpoolRenderers(unittest.TestCase):
def test_nixos_module_has_table_and_taps(self): def test_nixos_module_is_non_invasive(self):
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "2"}): # The NixOS module must NOT flip the host firewall backend or
out = netpool.render_nixos_module() # hand interfaces to systemd-networkd; it brings the pool up via
self.assertIn('networking.nftables.tables."bot_bottle_fc"', out) # a systemd oneshot that coexists with an iptables firewall.
self.assertIn('Name = "bbfc0"', out) root = Path(__file__).resolve().parents[2]
self.assertIn('Name = "bbfc1"', out) mod = (root / "nix" / "firecracker-netpool.nix").read_text()
self.assertIn("net.ipv4.ip_forward", out) self.assertNotIn("networking.nftables.enable = true", mod)
# fail-closed isolation rules present self.assertNotIn("systemd.network.enable = true", mod)
self.assertIn('iifname != "bbfc*" return', out) self.assertIn("bot-bottle-firecracker-netpool", mod) # the oneshot
self.assertIn("ct status dnat accept", out) self.assertIn(netpool.NFT_TABLE, mod)
self.assertIn('iifname != "${cfg.ifacePrefix}*" return', mod)
def test_shell_setup_reflects_overrides(self): def test_shell_setup_reflects_overrides(self):
with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}): with patch.dict(os.environ, {"BOT_BOTTLE_FC_POOL_SIZE": "3"}):