# bot-bottle Firecracker network pool — declarative NixOS module. # # The one-time privileged setup the Firecracker backend needs: a pool of # user- (or group-) owned point-to-point TAP devices plus a fail-closed # nftables table that confines every microVM to its own sidecar. # # NON-INVASIVE BY DESIGN. It does NOT flip `networking.nftables.enable` # (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 brings the pool up # on boot by running the SAME bring-up script as every other install # path (`scripts/firecracker-netpool.sh`) — so the TAP/nft logic lives in # exactly one place. The `inet ` 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, and the shared # script is non-destructive (it never tears down an existing TAP), so a # `nixos-rebuild switch` won't cut TAPs out from under running VMs. # # Single source of the pool defaults: bot_bottle/backend/firecracker/ # netpool.defaults.env. This module readFile-parses it for the option # defaults below, then passes the resolved values back to the script as # Environment=, so the host pool and the CLI launcher can't drift. { config, lib, pkgs, ... }: let cfg = config.services.bot-bottle-firecracker; # --- shared single-source defaults --------------------------------- # Parse the KEY=VALUE defaults file (the same one netpool.py and the # shell script read). Pure eval — just readFile, no import-from- # derivation — so it works for both flake and channel consumers. readDefaults = file: let lines = lib.splitString "\n" (builtins.readFile file); keep = l: l != "" && !(lib.hasPrefix "#" l) && lib.hasInfix "=" l; toPair = l: let parts = lib.splitString "=" l; in lib.nameValuePair (lib.head parts) (lib.concatStringsSep "=" (lib.tail parts)); in lib.listToAttrs (map toPair (lib.filter keep lines)); defaults = readDefaults ../bot_bottle/backend/firecracker/netpool.defaults.env; # The one bring-up implementation, shared with the sudo/systemd paths. netpoolScript = ../scripts/firecracker-netpool.sh; # /31 alignment == an even final octet (only bit 0 matters for base+2i). lastOctet = lib.toInt (lib.last (lib.splitString "." cfg.ipBase)); # The script needs ip/nft/sysctl + the usual coreutils. It gets every # pool value via the unit's Environment=, so it never reads the shared # defaults file (which isn't beside it once copied to the store). runtimePath = with pkgs; [ iproute2 nftables procps coreutils gnused ]; ownEnv = if cfg.group != null then { BOT_BOTTLE_FC_GROUP = cfg.group; } else { BOT_BOTTLE_FC_OWNER = cfg.owner; }; unitEnv = { BOT_BOTTLE_FC_POOL_SIZE = toString cfg.poolSize; BOT_BOTTLE_FC_IP_BASE = cfg.ipBase; BOT_BOTTLE_FC_IFACE_PREFIX = cfg.ifacePrefix; BOT_BOTTLE_FC_NFT_TABLE = cfg.tableName; } // ownEnv; in { options.services.bot-bottle-firecracker = { enable = lib.mkEnableOption "the bot-bottle Firecracker network pool (TAP devices + isolation nftables table)"; poolSize = lib.mkOption { type = lib.types.ints.positive; default = lib.toInt defaults.BOT_BOTTLE_FC_POOL_SIZE; defaultText = lib.literalMD "the shared `netpool.defaults.env` value"; description = "Number of pool slots (concurrent bottles). Must match BOT_BOTTLE_FC_POOL_SIZE."; }; ipBase = lib.mkOption { type = lib.types.str; default = defaults.BOT_BOTTLE_FC_IP_BASE; defaultText = lib.literalMD "the shared `netpool.defaults.env` value"; description = '' Base IPv4 of the /31 pool; slot i uses host = base + 2i. Must be /31-aligned (even final octet) and must match BOT_BOTTLE_FC_IP_BASE. The shared default is an obscure RFC-1918 /16 that dodges docker/libvirt/k8s/LAN and, deliberately, Tailscale's 100.64.0.0/10 CGNAT range. ''; }; ifacePrefix = lib.mkOption { type = lib.types.str; default = defaults.BOT_BOTTLE_FC_IFACE_PREFIX; defaultText = lib.literalMD "the shared `netpool.defaults.env` value"; description = "TAP interface name prefix. Must match BOT_BOTTLE_FC_IFACE_PREFIX."; }; owner = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "alice"; 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 { type = lib.types.str; default = defaults.BOT_BOTTLE_FC_NFT_TABLE; defaultText = lib.literalMD "the shared `netpool.defaults.env` value"; description = "nftables table name for the isolation boundary. Must match netpool.NFT_TABLE."; }; writeEnvFile = lib.mkOption { type = lib.types.bool; default = false; description = '' When true, write /etc/bot-bottle/firecracker.env with the matching BOT_BOTTLE_FC_* values, so the host pool and the CLI launcher can't drift. Source it before running `./cli.py`. ''; }; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = lib.mod lastOctet 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. boot.kernel.sysctl."net.ipv4.ip_forward" = 1; # One oneshot brings up the whole pool (TAPs + independent nft table) # by running the shared bring-up script. No networking.nftables.enable # / systemd.network.enable — see header. systemd.services."bot-bottle-firecracker-netpool" = { description = "bot-bottle Firecracker TAP pool + nft isolation table"; wantedBy = [ "multi-user.target" ]; after = [ "network-pre.target" ]; path = runtimePath; environment = unitEnv; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "${pkgs.bash}/bin/bash ${netpoolScript} up"; ExecStop = "${pkgs.bash}/bin/bash ${netpoolScript} down"; }; }; environment.etc."bot-bottle/firecracker.env" = lib.mkIf cfg.writeEnvFile { text = '' BOT_BOTTLE_FC_POOL_SIZE=${toString cfg.poolSize} BOT_BOTTLE_FC_IP_BASE=${cfg.ipBase} BOT_BOTTLE_FC_IFACE_PREFIX=${cfg.ifacePrefix} BOT_BOTTLE_FC_NFT_TABLE=${cfg.tableName} ''; }; }; }