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:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"}):
|
||||||
|
|||||||
Reference in New Issue
Block a user