Skip to content

egress-filter

Modules

Confine a network interface — a VM bridge, a container network — so the guests behind it can reach only a named allowlist of domains, and nothing else on the internet.

The problem

You want a domain allowlist: "these VMs may talk to example.com and cdn.example.org, period." But a packet filter matches IP addresses, not names, and the IPs behind a name rotate constantly (CDNs, load balancers, anycast). So a domain allowlist has to be turned into a live IP allowlist, and that IP set has to stay current or the filter either blocks legitimate traffic or leaks.

The approach

A FORWARD chain per interface accepts established/related traffic, allows DNS (see below), passes through the private/local ranges you list, accepts anything whose destination is in an ipset, and drops the rest. The whole design is about keeping that ipset correct. Two modes populate it:

resolve mode (simple)

A systemd timer re-resolves every domain (A, AAAA, and one level of CNAME) through your chosen upstream resolver every updateInterval (default 5m) and rebuilds the ipset.

  • Trap it handles: the ipset is rebuilt in a temporary set and then ipset swapped atomically into the name the FORWARD chain matches on. The live chain never sees a half-built allowlist — no window where valid destinations are briefly missing.
  • Limitation: it is blind to IP rotations that happen between polls. If a CDN hands the guest a fresh IP that the box hasn't resolved yet, that connection is dropped until the next refresh.

dnsmasq mode (robust)

A per-interface dnsmasq becomes the guests' only resolver. Its ipset=/domain/setname directive writes each freshly-answered IP into the ipset the instant it resolves — so the guest gets exactly the IP that is now allowed, with no polling gap.

  • Trap it handles: a guest could bypass interception by ignoring the offered resolver and querying 8.8.8.8 directly. So port 53 (UDP and TCP) from the interface is DNATed to the local dnsmasq, and IPv6 DNS from the interface is dropped outright. The guest cannot resolve names except through the interceptor, which means every name it can resolve is a name whose IP just entered the allowlist.

Static IP/CIDR overrides and private-network passthrough work in both modes.

Usage

{
  imports = [ ./egress-filter ];

  services.egressFilter = {
    enable = true;
    updateInterval = "5m"; # resolve-mode refresh cadence

    interfaces = {
      # Simple: periodic re-resolution.
      virbr0 = {
        enable = true;
        mode = "resolve";
        domains = [ "example.com" "cdn.example.org" ];
        allowedIPs = [ "8.8.8.8" ];
      };

      # Robust: live DNS interception + DNS redirect.
      virbr1 = {
        enable = true;
        mode = "dnsmasq";
        domains = [ "example.com" "cdn.example.org" ];
        allowedIPs = [ "8.8.8.8" "2606:4700:4700::1111" ];
        dnsmasq.listenAddress = "192.168.100.1"; # the bridge's gateway IP
      };
    };
  };
}

Options (per interface)

Option Default Meaning
enable false Filter this interface.
mode "resolve" "resolve" (poll) or "dnsmasq" (intercept).
domains [] Domain names to allow.
allowedIPs [] Static IPs/CIDRs (v4 or v6, auto-detected).
allowedIPv4 / allowedIPv6 [] Static IPs/CIDRs, explicitly typed.
allowPrivateNetworks true Pass through the privateNetworksV4/V6 ranges.
privateNetworksV4 loopback + RFC1918 + link-local Ranges treated as private.
privateNetworksV6 loopback + link-local + ULA Ranges treated as private.
allowDNS true Allow port 53 to any destination (ignored when mode = "dnsmasq" and dnsmasq.redirectDNS is on — that combination restricts DNS to the local interceptor instead).
dnsServers ["1.1.1.1" "8.8.8.8"] Upstream resolvers.
dnsmasq.listenAddress "" Gateway IP dnsmasq binds to (the bridge address). Required in dnsmasq mode — an empty value produces a broken config.
dnsmasq.port 53 Interceptor port.
dnsmasq.redirectDNS true DNAT the interface's port 53 to the interceptor.
dnsmasq.cacheSize 1000 dnsmasq DNS cache size.
dnsmasq.extraConfig "" Extra dnsmasq config lines.

Top-level: services.egressFilter.enable, .updateInterval, .interfaces.

Caveats

  • Match by destination is coarse. A shared IP (many domains behind one anycast address, or a big cloud front-end) means allowing one domain can incidentally allow siblings on the same IP. This is an inherent limit of IP-level filtering, not a bug.
  • resolve mode has a polling gap. Prefer dnsmasq mode when the allowed domains sit behind fast-rotating or huge IP pools.
  • dnsmasq mode owns DNS for the interface. Set dnsmasq.listenAddress to the interface's gateway IP and make sure guests are handed that resolver.
  • allowPrivateNetworks defaults to true and bypasses the domain allowlist. Out of the box every guest can reach the entire configured private range (all of 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local) — i.e. your whole LAN, the host's internal/admin services, the router, and other machines — no matter what domains says. The filter does not stop internal lateral movement in this configuration. When confining an untrusted guest, set allowPrivateNetworks = false, or narrow privateNetworksV4/privateNetworksV6 to just the bridge subnet and gateway it actually needs.
  • RFC6598 shared address space (the CGNAT range) is not in the private defaults. If your guests must reach a CGNAT or overlay-VPN range in that block, add it explicitly to privateNetworksV4.
  • The module forces networking.firewall.enable = true and installs its rules via firewall.extraCommands / extraStopCommands, so it coexists with the standard NixOS firewall rather than replacing it.

nftables backend

Supported, via a native translation — not the networking.firewall.extraCommands/extraStopCommands path the iptables backend uses (nixpkgs' nftables firewall, firewall-nftables.nix, hard- asserts both of those == ""). On networking.firewall.backend == "nftables" this module instead builds and owns a self-contained nftables table (family inet, name egress-filter) through its own systemd unit, egress-filter-nftables.service. Every mode and option this module supports on iptables — resolve, dnsmasq, dnsmasq.redirectDNS, static IPs, private-network passthrough, and IPv6 — has a native nftables equivalent; there is no remaining assertion blocking this backend.

The two enabling primitives

  1. dnsmasq populates an nftables set directly. The pinned dnsmasq (2.93, built with HAVE_NFTSET) accepts --nftset=/<domain>/<sel>#<family>#<table>#<set>, where <sel> is 4 or 6 (which record type feeds that spec) and <family> is nft's own table family (here always inet). This was verified against the real binary (dnsmasq --test accepts the generated directive) and its source (nftset.c, which — after splitting the spec on # — runs literally add element <family> <table> <set> { <ip> }, i.e. exactly the nft command this module's own table expects).
  2. nft -f <file> applies as one atomic netlink transaction. In resolve mode this replaces ipset swap: the timer writes a single file containing flush set ... followed by every add element ... for that refresh, and submits it in one nft -f call. The live enforcement chain never observes a half-rebuilt set — verified by applying that exact sequence against a real (network-namespaced) nftables/kernel instance, including a simulated redeploy (chain teardown + rebuild) between two such rebuilds, confirming the set survives untouched.

Table design: idempotent state, rebuilt enforcement

This module's whole point is dynamic state — the per-interface allow-sets, populated over time by the resolve-mode timer and/or the live dnsmasq interceptor. networking.nftables.tables deletes and fully recreates every table it declares on each nftables.service reload (see the fail2ban-ipset-geoip-cloudflare recipe's README for the same finding), which would wipe that state and reopen the exact polling/interception gap this module exists to close. So the table is kept outside networking.nftables.tables, split into two halves with different update rules:

  • Table + per-interface sets (egress_allow_<iface> / egress_allow6_<iface>): created idempotently (nft add table/ add set, which — unlike nft create — is a no-op if it already exists) and never flushed by setup or teardown. This is what survives a redeploy or a nftables.service reload.
  • Enforcement chains (the per-interface forward-hook chain, the shared nat-hook DNS-redirect chain, and their dispatcher chains): pure functions of static config, so they're deleted and rebuilt from scratch on every run of egress-filter-nftables.service — the dispatcher chains are deleted before the chains they jump to, so an unreferenced per- interface chain can always be deleted cleanly. This exactly mirrors how fail2ban-ipset-geoip-cloudflare treats its enforcing "input" chain versus its banned-IP set.

Static IPs (allowedIPv4/allowedIPv6/allowedIPs) are seeded into the same sets via idempotent add element (never a flush), so they coexist safely with whatever the resolve timer or dnsmasq have already added.

Rule order (load-bearing, identical to the iptables path)

Per enabled interface: ct state established,related accept → the DNS branch (DNAT-and-accept in dnsmasq+redirectDNS mode, with an explicit early drop of IPv6 DNS traffic before the allow-set is even consulted — otherwise a guest could resolve names by querying an already-allowed domain's own IPv6 address on port 53; or a plain accept when allowDNS) → private-network accepts → the allow-set lookup → drop. An inet-family table can match ip/ip6 fields side by side in the same chain, so (unlike the iptables/ip6tables pair) there's one merged chain per interface instead of two.

Known residual gaps

  • networking.nftables.flushRuleset (defaulted on for hosts with system.stateVersion older than 23.11, or explicitly via networking.nftables.ruleset/rulesetFile): every start or reload of nftables.service — including ones triggered by unrelated firewall changes elsewhere on the host — runs flush ruleset, wiping this module's table (dynamic allow-sets included) until egress-filter-nftables.service next runs. Affected interfaces are unfiltered (fail-open) in that window. This module emits a config.warnings entry when it detects this combination; consider networking.nftables.flushRuleset = false if nothing else on the host needs it.
  • No table cleanup on full disable. Disabling services.egressFilter removes this module's systemd unit from the config entirely, so only its old ExecStop (which deletes just the enforcement chains, by design — see above) ever runs. The table and its allow-sets are never deleted and linger in the kernel until an operator runs nft delete table inet egress-filter by hand. This is the same tradeoff fail2ban-ipset-geoip-cloudflare accepts for its banned-IP set.
  • dnsmasq's live population only ever adds, never removes, on both backends — identical to the pre-existing ipset behavior, not a regression from this port.

Verification performed

  • nix eval: the iptables-backend resolved config (extraCommands, extraStopCommands, environment.systemPackages, networking.firewall.extraPackages, and every generated systemd unit's ExecStart/ExecStartPost/preStart, down to the exact /nix/store derivation path) is byte-for-byte identical before and after this port.
  • Standalone eval with networking.firewall.backend = "nftables" succeeds with zero failed assertions, for resolve mode, dnsmasq mode, dnsmasq.redirectDNS, static IPv4/IPv6, private-network passthrough, and IPv6 enabled/disabled.
  • Two NixOS VM tests (egress-filter-nftables-resolve and egress-filter-nftables-dnsmasq, siblings of the pre-existing iptables egress-filter-resolve/egress-filter-dnsmasq; they live in the configuration this recipe was extracted from and are not shipped alongside default.nix) boot a hypervisor plus guest VMs on the nftables backend and assert a client behind the filtered interface can reach an allowed domain and cannot reach a blocked one — including, for dnsmasq mode, that a guest cannot bypass interception by querying an outside resolver directly (the DNAT redirect catches it anyway), and for resolve mode, that the allow-set survives a simulated redeploy (chain teardown + rebuild) with its entries intact. Both passed end-to-end against a real kernel.

Source

modules/egress-filter/default.nix
# egress-filter — confine an interface to a DNS-name allowlist
#
# Problem: you have a VM bridge or container network and you want to allow the
# guests behind it to reach *only* a named set of domains — but a firewall
# matches IP addresses, not names, and the IPs behind a name rotate.
#
# Two strategies, both landing in the same per-interface ipset that a FORWARD
# chain gates on:
#
#   resolve mode  — a timer periodically re-resolves the domains and rebuilds
#                   the ipset. Simple, but blind to IP rotations that happen
#                   between polls.
#
#   dnsmasq mode  — a per-interface dnsmasq is the guests' only resolver; it
#                   writes each freshly-answered IP into the ipset the instant
#                   it resolves, and port 53 is DNATed to it so a guest cannot
#                   bypass interception by talking to an outside resolver.
#
# Key trap handled below: in resolve mode the ipset is rebuilt in a *temporary*
# set and atomically `ipset swap`ped into place, so the live FORWARD chain never
# matches against a half-built allowlist.
#
# Backends: works on both `networking.firewall.backend = "iptables"` (via
# ipset + iptables, described above) and `"nftables"` (a native translation --
# its own self-managed table, `nftset=` in place of `ipset=`, `nft -f` as the
# atomic-rebuild primitive in place of `ipset swap`). See the "nftables
# backend" comment block below (just above `enabledInterfaces`) and the
# README for that design.
#
# This is a self-contained NixOS module. Import it and configure
# `services.egressFilter`. Nothing here is host- or site-specific.

{
  config,
  lib,
  pkgs,
  ...
}:

with lib;
let
  cfg = config.services.egressFilter;

  ipv6Enabled = config.networking.enableIPv6;

  iptables = "${pkgs.iptables}/bin/iptables";
  ip6tables = "${pkgs.iptables}/bin/ip6tables";
  ipset = "${pkgs.ipset}/bin/ipset";
  dig = "${pkgs.dnsutils}/bin/dig";
  grep = "${pkgs.gnugrep}/bin/grep";
  sort = "${pkgs.coreutils}/bin/sort";
  paste = "${pkgs.coreutils}/bin/paste";

  # nftables backend. See the "nftables backend" block below (right above
  # `enabledInterfaces`) for the design and the big comment explaining why
  # a native port is possible and how it stays safe.
  nft = config.networking.firewall.backend == "nftables";
  nftBin = "${pkgs.nftables}/bin/nft";
  nftTable = "egress-filter";

  interfaceOptions =
    { name, ... }:
    {
      options = {
        name = mkOption {
          type = types.str;
          default = name;
          description = "The name of the network interface";
        };

        enable = mkEnableOption "Egress filtering for this interface";

        mode = mkOption {
          type = types.enum [
            "resolve"
            "dnsmasq"
          ];
          default = "resolve";
          description = ''
            Strategy for determining allowed IPs:
            - resolve: Periodically resolve domains and update ipsets (simple, but may miss IP changes)
            - dnsmasq: Intercept DNS queries via dnsmasq and add IPs in real-time (more robust)
          '';
        };

        domains = mkOption {
          type = types.listOf types.str;
          default = [ ];
          example = [
            "example.com"
            "cdn.example.org"
          ];
          description = ''
            List of domain names to allow outgoing connections to.
            These will be resolved to IP addresses and added to an allowlist.
          '';
        };

        allowedIPs = mkOption {
          type = types.listOf types.str;
          default = [ ];
          example = [
            "8.8.8.8"
            "1.1.1.1/32"
            "203.0.113.0/24"
            "2001:db8::/32"
          ];
          description = ''
            Static IP addresses or CIDR ranges to allow, beyond the resolved domains.
            Supports both IPv4 and IPv6. IPv6 addresses are automatically detected
            and added to the appropriate ipset.
          '';
        };

        allowedIPv4 = mkOption {
          type = types.listOf types.str;
          default = [ ];
          example = [
            "8.8.8.8"
            "203.0.113.0/24"
          ];
          description = ''
            Static IPv4 addresses or CIDR ranges to allow.
          '';
        };

        allowedIPv6 = mkOption {
          type = types.listOf types.str;
          default = [ ];
          example = [
            "2001:db8::/32"
            "2606:4700:4700::1111"
          ];
          description = ''
            Static IPv6 addresses or CIDR ranges to allow.
          '';
        };

        allowPrivateNetworks = mkOption {
          type = types.bool;
          default = true;
          description = ''
            Whether to allow traffic to private/local networks (RFC1918, loopback,
            link-local). The exact ranges are controlled by `privateNetworksV4`
            and `privateNetworksV6`.

            Security note: the default (`true`) passes through the *entire*
            configured private range regardless of the domain allowlist. A guest
            you meant to confine to a few domains can still reach the whole LAN,
            the host's internal/admin services, the router, and any other machine
            on those ranges. When confining an untrusted guest, set this to
            `false` — or narrow `privateNetworksV4`/`privateNetworksV6` to just
            the bridge subnet and gateway the guest legitimately needs.
          '';
        };

        privateNetworksV4 = mkOption {
          type = types.listOf types.str;
          default = [
            "127.0.0.0/8"
            "10.0.0.0/8"
            "172.16.0.0/12"
            "192.168.0.0/16"
            "169.254.0.0/16"
          ];
          description = ''
            IPv4 ranges treated as "private" and passed through when
            `allowPrivateNetworks` is enabled. Defaults to loopback, the three
            RFC1918 blocks, and link-local. Add the RFC6598 shared-address-space
            block here if your deployment routes it (e.g. a CGNAT or overlay-VPN
            range that guests must reach).
          '';
        };

        privateNetworksV6 = mkOption {
          type = types.listOf types.str;
          default = [
            "::1/128"
            "fe80::/10"
            "fc00::/7"
            "fd00::/8"
          ];
          description = ''
            IPv6 ranges treated as "private" and passed through when
            `allowPrivateNetworks` is enabled. Defaults to loopback, link-local,
            and unique-local.
          '';
        };

        allowDNS = mkOption {
          type = types.bool;
          default = true;
          description = ''
            Whether to allow DNS queries (UDP/TCP port 53) to any destination.
            In dnsmasq mode, this is automatically restricted to the local dnsmasq instance.
          '';
        };

        dnsServers = mkOption {
          type = types.listOf types.str;
          default = [
            "1.1.1.1"
            "8.8.8.8"
          ];
          description = ''
            Upstream DNS servers to use for resolving domains.
            In resolve mode: used by dig for periodic resolution.
            In dnsmasq mode: used as upstream servers for dnsmasq.
          '';
        };

        dnsmasq = {
          listenAddress = mkOption {
            type = types.str;
            default = "";
            example = "192.168.100.1";
            description = ''
              IP address for dnsmasq to listen on. Should be the gateway IP of
              the bridge (the address guests use as their resolver). Required in
              dnsmasq mode: if left empty, dnsmasq binds nothing usable and the
              DNS-redirect DNAT points at a bare port, so the interceptor never
              answers.
            '';
          };

          port = mkOption {
            type = types.port;
            default = 53;
            description = "Port for dnsmasq to listen on.";
          };

          redirectDNS = mkOption {
            type = types.bool;
            default = true;
            description = ''
              Whether to redirect all DNS traffic from the interface to the local dnsmasq.
              This ensures guests cannot bypass the DNS interception.
            '';
          };

          cacheSize = mkOption {
            type = types.int;
            default = 1000;
            description = "DNS cache size for dnsmasq.";
          };

          extraConfig = mkOption {
            type = types.lines;
            default = "";
            description = "Extra dnsmasq configuration lines.";
          };
        };
      };
    };

  ipsetName = iface: "egress_allow_${iface}";
  ipsetName6 = iface: "egress_allow6_${iface}";

  mkDnsmasqIpsetConfig =
    name: interface:
    let
      ipset4 = ipsetName name;
      ipset6 = ipsetName6 name;
      ipsetSpec = if ipv6Enabled then "${ipset4},${ipset6}" else ipset4;
    in
    concatMapStringsSep "\n" (domain: "ipset=/${domain}/${ipsetSpec}") interface.domains;

  # nftables equivalent of mkDnsmasqIpsetConfig. dnsmasq 2.93 (HAVE_NFTSET)
  # writes each freshly-answered IP straight into an nftables set via
  # --nftset=/<domain>/<sel>#<family>#<table>#<set>, where <sel> is "4" or
  # "6" (restricts which record type feeds that set spec) and <family> is
  # nft's own table family (here always "inet", since this module's table
  # is family inet). Verified against the real pinned dnsmasq: `dnsmasq
  # --test` accepts this exact directive, and dnsmasq's nftset.c builds
  # the in-process command as literally `add element <family> <table>
  # <set> { <ip> }` after splitting on '#' -- i.e. it ends up running
  # precisely the nft command this spec names, nothing more exotic.
  mkDnsmasqNftsetConfig =
    name: interface:
    let
      spec4 = "4#inet#${nftTable}#${ipsetName name}";
      spec6 = "6#inet#${nftTable}#${ipsetName6 name}";
      spec = if ipv6Enabled then "${spec4},${spec6}" else spec4;
    in
    concatMapStringsSep "\n" (domain: "nftset=/${domain}/${spec}") interface.domains;

  mkDnsmasqConfig =
    name: interface:
    pkgs.writeText "dnsmasq-egress-${name}.conf" ''
      no-resolv

      ${concatMapStringsSep "\n" (server: "server=${server}") interface.dnsServers}

      listen-address=${interface.dnsmasq.listenAddress}
      port=${toString interface.dnsmasq.port}
      bind-interfaces

      cache-size=${toString interface.dnsmasq.cacheSize}


      ${if nft then mkDnsmasqNftsetConfig name interface else mkDnsmasqIpsetConfig name interface}

      ${interface.dnsmasq.extraConfig}
    '';

  mkResolveScript =
    name: interface:
    pkgs.writeShellScript "egress-resolve-${name}" ''
      set -euo pipefail

      IPSET4="${ipsetName name}"
      IPSET6="${ipsetName6 name}"
      TMPFILE=$(mktemp)
      TMPFILE6=$(mktemp)

      ${ipset} create -exist "$IPSET4" hash:net family inet hashsize 1024 maxelem 65536
      ${optionalString ipv6Enabled ''
        ${ipset} create -exist "$IPSET6" hash:net family inet6 hashsize 1024 maxelem 65536
      ''}

      echo "Resolving domains for ${name}..."

      ${concatMapStringsSep "\n" (ip: ''
        echo "${ip}" >> "$TMPFILE"
      '') interface.allowedIPv4}

      ${optionalString ipv6Enabled (
        concatMapStringsSep "\n" (ip: ''
          echo "${ip}" >> "$TMPFILE6"
        '') interface.allowedIPv6
      )}

      ${concatMapStringsSep "\n" (ip: ''
        if echo "${ip}" | ${grep} -qE ':'; then
          ${optionalString ipv6Enabled ''echo "${ip}" >> "$TMPFILE6"''}
        else
          echo "${ip}" >> "$TMPFILE"
        fi
      '') interface.allowedIPs}

      ${concatMapStringsSep "\n" (domain: ''
        echo "Resolving ${domain}..."
        ${dig} +short ${domain} A @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' >> "$TMPFILE" || true
        ${optionalString ipv6Enabled ''
          ${dig} +short ${domain} AAAA @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9a-fA-F:]+$' >> "$TMPFILE6" || true
        ''}
        for cname in $(${dig} +short ${domain} CNAME @${head interface.dnsServers} 2>/dev/null || true); do
          ${dig} +short "$cname" A @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' >> "$TMPFILE" || true
          ${optionalString ipv6Enabled ''
            ${dig} +short "$cname" AAAA @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9a-fA-F:]+$' >> "$TMPFILE6" || true
          ''}
        done
      '') interface.domains}

      # Rebuild the allowlist in a temp set and atomically swap it in, so the
      # live FORWARD chain never matches a half-built set.
      RESTORE_FILE=$(mktemp)
      SWAP_SET="egress_tmp_${name}"
      SWAP_SET6="egress_tmp6_${name}"

      echo "create $SWAP_SET hash:net family inet hashsize 1024 maxelem 65536" >> "$RESTORE_FILE"
      ${sort} -u "$TMPFILE" | while read -r ip; do
        [ -n "$ip" ] && echo "add $SWAP_SET $ip" >> "$RESTORE_FILE"
      done

      ${optionalString ipv6Enabled ''
        echo "create $SWAP_SET6 hash:net family inet6 hashsize 1024 maxelem 65536" >> "$RESTORE_FILE"
        ${sort} -u "$TMPFILE6" | while read -r ip; do
          [ -n "$ip" ] && echo "add $SWAP_SET6 $ip" >> "$RESTORE_FILE"
        done
      ''}

      ${ipset} restore -! < "$RESTORE_FILE"
      ${ipset} swap "$SWAP_SET" "$IPSET4" 2>/dev/null || ${ipset} rename "$SWAP_SET" "$IPSET4"
      ${ipset} destroy "$SWAP_SET" 2>/dev/null || true

      ${optionalString ipv6Enabled ''
        ${ipset} swap "$SWAP_SET6" "$IPSET6" 2>/dev/null || ${ipset} rename "$SWAP_SET6" "$IPSET6"
        ${ipset} destroy "$SWAP_SET6" 2>/dev/null || true
      ''}

      rm -f "$TMPFILE" "$TMPFILE6" "$RESTORE_FILE"

      echo "Egress filter for ${name} updated with $(${ipset} list "$IPSET4" 2>/dev/null | ${grep} -c '^[0-9]' || echo 0) IPv4 entries"
      ${optionalString ipv6Enabled ''
        echo "Egress filter for ${name} updated with $(${ipset} list "$IPSET6" 2>/dev/null | ${grep} -c '^[0-9a-fA-F]' || echo 0) IPv6 entries"
      ''}
    '';

  mkStaticIpsScript =
    name: interface:
    pkgs.writeShellScript "egress-static-ips-${name}" ''
      set -euo pipefail

      IPSET4="${ipsetName name}"
      IPSET6="${ipsetName6 name}"

      echo "Adding static IPs for ${name}..."

      ${concatMapStringsSep "\n" (ip: ''
        ${ipset} add -exist "$IPSET4" "${ip}"
      '') interface.allowedIPv4}

      ${optionalString ipv6Enabled (
        concatMapStringsSep "\n" (ip: ''
          ${ipset} add -exist "$IPSET6" "${ip}"
        '') interface.allowedIPv6
      )}

      ${concatMapStringsSep "\n" (ip: ''
        if echo "${ip}" | ${grep} -qE ':'; then
          ${optionalString ipv6Enabled ''${ipset} add -exist "$IPSET6" "${ip}"''}
        else
          ${ipset} add -exist "$IPSET4" "${ip}"
        fi
      '') interface.allowedIPs}

      echo "Static IPs added for ${name}"
    '';

  generateIptablesRules =
    name: interface:
    let
      chainName = "EGRESS_FILTER_${name}";
      inputInterfaceFlag = "-i ${name}";
      listenAddr = interface.dnsmasq.listenAddress;
      dnsPort = interface.dnsmasq.port;
    in
    optionalString interface.enable ''
      ${iptables} -N ${chainName} 2>/dev/null || ${iptables} -F ${chainName}

      ${iptables} -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT

      ${
        if interface.mode == "dnsmasq" && interface.dnsmasq.redirectDNS then
          ''
            ${iptables} -t nat -D PREROUTING ${inputInterfaceFlag} -p udp --dport 53 -j DNAT --to-destination ${listenAddr}:${toString dnsPort} 2>/dev/null || true
            ${iptables} -t nat -D PREROUTING ${inputInterfaceFlag} -p tcp --dport 53 -j DNAT --to-destination ${listenAddr}:${toString dnsPort} 2>/dev/null || true
            ${iptables} -t nat -I PREROUTING 1 ${inputInterfaceFlag} -p udp --dport 53 -j DNAT --to-destination ${listenAddr}:${toString dnsPort}
            ${iptables} -t nat -I PREROUTING 1 ${inputInterfaceFlag} -p tcp --dport 53 -j DNAT --to-destination ${listenAddr}:${toString dnsPort}
            ${iptables} -A ${chainName} -p udp -d ${listenAddr} --dport ${toString dnsPort} -j ACCEPT
            ${iptables} -A ${chainName} -p tcp -d ${listenAddr} --dport ${toString dnsPort} -j ACCEPT
          ''
        else
          optionalString interface.allowDNS ''
            ${iptables} -A ${chainName} -p udp --dport 53 -j ACCEPT
            ${iptables} -A ${chainName} -p tcp --dport 53 -j ACCEPT
          ''
      }

      ${optionalString interface.allowPrivateNetworks (
        concatMapStringsSep "\n" (
          net: "${iptables} -A ${chainName} -d ${net} -j ACCEPT"
        ) interface.privateNetworksV4
      )}

      ${iptables} -A ${chainName} -m set --match-set ${ipsetName name} dst -j ACCEPT

      ${iptables} -A ${chainName} -j DROP

      ${iptables} -D FORWARD ${inputInterfaceFlag} -j ${chainName} 2>/dev/null || true
      ${iptables} -I FORWARD 1 ${inputInterfaceFlag} -j ${chainName}

      ${optionalString ipv6Enabled ''
        ${ip6tables} -N ${chainName} 2>/dev/null || ${ip6tables} -F ${chainName}

        ${ip6tables} -A ${chainName} -m state --state ESTABLISHED,RELATED -j ACCEPT

        ${
          if interface.mode == "dnsmasq" && interface.dnsmasq.redirectDNS then
            ''
              ${ip6tables} -A ${chainName} -p udp --dport 53 -j DROP
              ${ip6tables} -A ${chainName} -p tcp --dport 53 -j DROP
            ''
          else
            optionalString interface.allowDNS ''
              ${ip6tables} -A ${chainName} -p udp --dport 53 -j ACCEPT
              ${ip6tables} -A ${chainName} -p tcp --dport 53 -j ACCEPT
            ''
        }

        ${optionalString interface.allowPrivateNetworks (
          concatMapStringsSep "\n" (
            net: "${ip6tables} -A ${chainName} -d ${net} -j ACCEPT"
          ) interface.privateNetworksV6
        )}

        ${ip6tables} -A ${chainName} -m set --match-set ${ipsetName6 name} dst -j ACCEPT

        ${ip6tables} -A ${chainName} -j DROP

        ${ip6tables} -D FORWARD ${inputInterfaceFlag} -j ${chainName} 2>/dev/null || true
        ${ip6tables} -I FORWARD 1 ${inputInterfaceFlag} -j ${chainName}
      ''}
    '';

  cleanupRules =
    name: interface:
    let
      chainName = "EGRESS_FILTER_${name}";
      inputInterfaceFlag = "-i ${name} ";
    in
    optionalString interface.enable ''
      ${optionalString (interface.mode == "dnsmasq" && interface.dnsmasq.redirectDNS) ''
        ${iptables} -t nat -D PREROUTING ${inputInterfaceFlag}-p udp --dport 53 -j DNAT --to-destination ${interface.dnsmasq.listenAddress}:${toString interface.dnsmasq.port} 2>/dev/null || true
        ${iptables} -t nat -D PREROUTING ${inputInterfaceFlag}-p tcp --dport 53 -j DNAT --to-destination ${interface.dnsmasq.listenAddress}:${toString interface.dnsmasq.port} 2>/dev/null || true
      ''}

      ${iptables} -D FORWARD ${inputInterfaceFlag}-j ${chainName} 2>/dev/null || true
      ${iptables} -F ${chainName} 2>/dev/null || true
      ${iptables} -X ${chainName} 2>/dev/null || true

      ${optionalString ipv6Enabled ''
        ${ip6tables} -D FORWARD ${inputInterfaceFlag}-j ${chainName} 2>/dev/null || true
        ${ip6tables} -F ${chainName} 2>/dev/null || true
        ${ip6tables} -X ${chainName} 2>/dev/null || true
      ''}

      ${ipset} destroy ${ipsetName name} 2>/dev/null || true
      ${optionalString ipv6Enabled ''
        ${ipset} destroy ${ipsetName6 name} 2>/dev/null || true
      ''}
    '';

  createInitialIpsets =
    name: interface:
    optionalString interface.enable ''
      ${ipset} create -exist ${ipsetName name} hash:net family inet hashsize 1024 maxelem 65536
      ${optionalString ipv6Enabled ''
        ${ipset} create -exist ${ipsetName6 name} hash:net family inet6 hashsize 1024 maxelem 65536
      ''}
    '';

  # ---------------------------------------------------------------------
  # nftables backend
  #
  # nixpkgs' nftables-based firewall (firewall-nftables.nix) hard-asserts
  # networking.firewall.extraCommands/extraStopCommands == "" -- this
  # module's per-interface FORWARD gating, ipsets, and (in dnsmasq mode)
  # the DNS DNAT redirect can't be driven through them on that backend.
  # Two primitives, both checked against real binaries/kernel state (not
  # just `nft -c` syntax-checked) rather than assumed, make a faithful,
  # atomic port possible:
  #
  #   1. dnsmasq (2.93, compiled with HAVE_NFTSET) writes each freshly-
  #      answered IP straight into an nftables set (mkDnsmasqNftsetConfig
  #      above) -- verified with `dnsmasq --test` and by reading nftset.c.
  #   2. `nft -f <file>` applies its whole contents as ONE atomic netlink
  #      transaction. In resolve mode this replaces the ipset-swap trick:
  #      a file containing `flush set ...` followed by every
  #      `add element ...` is submitted in a single `nft -f` call, so the
  #      live enforcement chain never matches a half-rebuilt set -- this
  #      exact sequence was applied against a real (network-namespaced)
  #      nft/kernel instance during development, including a simulated
  #      "redeploy" (chain teardown+rebuild) between two rebuilds, to
  #      confirm the set survives untouched.
  #
  # This module keeps its own table (family inet, name "egress-filter")
  # OUTSIDE `networking.nftables.tables`: like fail2ban-ipset-geoip-
  # cloudflare, this module's whole point is dynamic state (the per-
  # interface allow-sets, populated over time by the resolve-mode timer
  # and/or the live dnsmasq interceptor), and networking.nftables.tables
  # deletes+recreates every declared table on every nftables.service
  # reload -- wiping that state and reopening exactly the polling/
  # interception gap this module exists to close. So:
  #   * table + per-interface sets: idempotent `nft add table`/`add set`
  #     (never flushed by setup or teardown), run from this module's own
  #     systemd unit -- survives redeploys and nftables.service reloads.
  #   * the *enforcement* chains (forward-hook per-interface gating, and
  #     the nat-hook DNS DNAT) are pure functions of static config -- safe
  #     to `delete chain` + rebuild from scratch on every run, exactly
  #     like fail2ban's enforcing "input" chain. The dispatcher chains are
  #     deleted before the chains they jump to, so deleting an
  #     unreferenced per-interface chain never fails on a live jump.
  #
  # Known residual gaps (see the README, "nftables backend", for detail):
  #   * networking.nftables.flushRuleset (on by default for hosts with
  #     stateVersion < 23.11) makes nftables.service issue `flush ruleset`
  #     on every start/reload, wiping this table -- including the dynamic
  #     allow-sets -- until this module's own unit next runs.
  #   * disabling services.egressFilter entirely removes this module's
  #     systemd unit from the config, so only its *old* ExecStop (chain-
  #     only teardown) ever runs; the table and its sets are never
  #     deleted and linger until an operator runs
  #     `nft delete table inet egress-filter` by hand.
  isV6Addr = ip: hasInfix ":" ip;
  nftStaticV4 = interface: interface.allowedIPv4 ++ filter (ip: !(isV6Addr ip)) interface.allowedIPs;
  nftStaticV6 = interface: interface.allowedIPv6 ++ filter isV6Addr interface.allowedIPs;

  nftForwardChain = name: "egress_fwd_${name}";

  # Idempotent -- create-if-missing, never flush. Safe to run redundantly
  # from more than one unit (mirrors createInitialIpsets/mkResolveScript's
  # own belt-and-suspenders `ipset create -exist`).
  nftEnsureSetsCommands =
    name: interface:
    ''
      ${nftBin} add table inet ${nftTable}
      ${nftBin} add set inet ${nftTable} ${ipsetName name} '{ type ipv4_addr; flags interval; }'
      ${optionalString ipv6Enabled ''
        ${nftBin} add set inet ${nftTable} ${ipsetName6 name} '{ type ipv6_addr; flags interval; }'
      ''}
    '';

  # Idempotent element adds -- never flushes, so this can never wipe
  # entries the resolve-mode timer or the live dnsmasq interceptor
  # already wrote into the same set.
  nftStaticIpsCommands =
    name: interface:
    let
      v4 = nftStaticV4 interface;
      v6 = nftStaticV6 interface;
    in
    ''
      ${optionalString (v4 != [ ]) ''
        ${nftBin} add element inet ${nftTable} ${ipsetName name} '{ ${concatStringsSep "," v4} }'
      ''}
      ${optionalString (ipv6Enabled && v6 != [ ]) ''
        ${nftBin} add element inet ${nftTable} ${ipsetName6 name} '{ ${concatStringsSep "," v6} }'
      ''}
    '';

  mkNftStaticIpsScript =
    name: interface:
    pkgs.writeShellScript "egress-static-ips-nft-${name}" ''
      set -euo pipefail
      ${nftStaticIpsCommands name interface}
      echo "Static IPs added for ${name} (nftables)"
    '';

  # Resolve-mode nftables equivalent of mkResolveScript. Identical domain/
  # CNAME resolution to the iptables path; the only difference is the
  # atomic-rebuild mechanism (`nft -f` transaction instead of `ipset
  # swap`) -- see the header comment above for why that is an equivalent
  # (and separately-verified) guarantee.
  mkNftResolveScript =
    name: interface:
    pkgs.writeShellScript "egress-resolve-nft-${name}" ''
      set -euo pipefail

      SET4="${ipsetName name}"
      SET6="${ipsetName6 name}"
      TMPFILE=$(mktemp)
      TMPFILE6=$(mktemp)

      ${nftEnsureSetsCommands name interface}

      echo "Resolving domains for ${name}..."

      ${concatMapStringsSep "\n" (ip: ''
        echo "${ip}" >> "$TMPFILE"
      '') interface.allowedIPv4}

      ${optionalString ipv6Enabled (
        concatMapStringsSep "\n" (ip: ''
          echo "${ip}" >> "$TMPFILE6"
        '') interface.allowedIPv6
      )}

      ${concatMapStringsSep "\n" (ip: ''
        if echo "${ip}" | ${grep} -qE ':'; then
          ${optionalString ipv6Enabled ''echo "${ip}" >> "$TMPFILE6"''}
        else
          echo "${ip}" >> "$TMPFILE"
        fi
      '') interface.allowedIPs}

      ${concatMapStringsSep "\n" (domain: ''
        echo "Resolving ${domain}..."
        ${dig} +short ${domain} A @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' >> "$TMPFILE" || true
        ${optionalString ipv6Enabled ''
          ${dig} +short ${domain} AAAA @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9a-fA-F:]+$' >> "$TMPFILE6" || true
        ''}
        for cname in $(${dig} +short ${domain} CNAME @${head interface.dnsServers} 2>/dev/null || true); do
          ${dig} +short "$cname" A @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' >> "$TMPFILE" || true
          ${optionalString ipv6Enabled ''
            ${dig} +short "$cname" AAAA @${head interface.dnsServers} 2>/dev/null | ${grep} -E '^[0-9a-fA-F:]+$' >> "$TMPFILE6" || true
          ''}
        done
      '') interface.domains}

      # Atomic rebuild: flush + repopulate in ONE nft -f transaction, so
      # the live FORWARD chain never matches a half-built set -- the same
      # guarantee the iptables path gets from `ipset swap`.
      NFTFILE=$(mktemp)
      {
        echo "flush set inet ${nftTable} $SET4"
        V4LIST=$(${sort} -u "$TMPFILE" | ${paste} -sd, -)
        if [ -n "$V4LIST" ]; then
          echo "add element inet ${nftTable} $SET4 { $V4LIST }"
        fi
        ${optionalString ipv6Enabled ''
          echo "flush set inet ${nftTable} $SET6"
          V6LIST=$(${sort} -u "$TMPFILE6" | ${paste} -sd, -)
          if [ -n "$V6LIST" ]; then
            echo "add element inet ${nftTable} $SET6 { $V6LIST }"
          fi
        ''}
      } > "$NFTFILE"

      ${nftBin} -f "$NFTFILE"
      rm -f "$TMPFILE" "$TMPFILE6" "$NFTFILE"

      echo "Egress filter for ${name} updated (nftables)"
    '';

  # Per-interface FORWARD-hook chain: same rule content and ORDER as
  # generateIptablesRules -- established-first, DNS handling, private-net
  # accepts, allow-set lookup, drop-last -- merged into one chain per
  # interface. An inet-family table can match `ip`/`ip6` fields side by
  # side in the same chain, so unlike the iptables/ip6tables pair below
  # there's no need to duplicate the whole chain per protocol; only the
  # v6-specific DNS-bypass guard (see isDnsmasqRedirect below) needs an
  # explicit family qualifier.
  nftForwardChainCommands =
    name: interface:
    let
      chain = nftForwardChain name;
      listenAddr = interface.dnsmasq.listenAddress;
      dnsPort = toString interface.dnsmasq.port;
      set4 = ipsetName name;
      set6 = ipsetName6 name;
      isDnsmasqRedirect = interface.mode == "dnsmasq" && interface.dnsmasq.redirectDNS;
    in
    optionalString interface.enable ''
      ${nftBin} add chain inet ${nftTable} ${chain}
      ${nftBin} add rule inet ${nftTable} ${chain} ct state established,related accept

      ${
        if isDnsmasqRedirect then
          ''
            ${nftBin} add rule inet ${nftTable} ${chain} ip daddr ${listenAddr} udp dport ${dnsPort} accept
            ${nftBin} add rule inet ${nftTable} ${chain} ip daddr ${listenAddr} tcp dport ${dnsPort} accept
          ''
          # No v6 DNAT target exists (the dnsmasq listener is v4-only), so
          # a v6 DNS query's destination is never rewritten. Drop it
          # explicitly, and BEFORE the allow-set lookup below: otherwise a
          # guest could bypass interception entirely by querying an
          # already-allowed domain's own IPv6 address on port 53.
          + optionalString ipv6Enabled ''
            ${nftBin} add rule inet ${nftTable} ${chain} meta nfproto ipv6 udp dport 53 drop
            ${nftBin} add rule inet ${nftTable} ${chain} meta nfproto ipv6 tcp dport 53 drop
          ''
        else
          optionalString interface.allowDNS ''
            ${nftBin} add rule inet ${nftTable} ${chain} udp dport 53 accept
            ${nftBin} add rule inet ${nftTable} ${chain} tcp dport 53 accept
          ''
      }

      ${optionalString interface.allowPrivateNetworks (
        concatMapStringsSep "\n" (
          net: "${nftBin} add rule inet ${nftTable} ${chain} ip daddr ${net} accept"
        ) interface.privateNetworksV4
      )}
      ${optionalString (interface.allowPrivateNetworks && ipv6Enabled) (
        concatMapStringsSep "\n" (
          net: "${nftBin} add rule inet ${nftTable} ${chain} ip6 daddr ${net} accept"
        ) interface.privateNetworksV6
      )}

      ${nftBin} add rule inet ${nftTable} ${chain} ip daddr @${set4} accept
      ${optionalString ipv6Enabled ''
        ${nftBin} add rule inet ${nftTable} ${chain} ip6 daddr @${set6} accept
      ''}

      ${nftBin} add rule inet ${nftTable} ${chain} drop
    '';

  nftDeleteForwardChainCommand =
    name: interface:
    optionalString interface.enable "${nftBin} delete chain inet ${nftTable} ${nftForwardChain name} 2>/dev/null || true";

  nftNatCommands =
    name: interface:
    optionalString (interface.enable && interface.mode == "dnsmasq" && interface.dnsmasq.redirectDNS) ''
      ${nftBin} add rule inet ${nftTable} nat_dispatch iifname "${name}" udp dport 53 dnat ip to ${interface.dnsmasq.listenAddress}:${toString interface.dnsmasq.port}
      ${nftBin} add rule inet ${nftTable} nat_dispatch iifname "${name}" tcp dport 53 dnat ip to ${interface.dnsmasq.listenAddress}:${toString interface.dnsmasq.port}
    '';

  nftDispatchCommand =
    name: interface:
    optionalString interface.enable ''
      ${nftBin} add rule inet ${nftTable} fwd_dispatch iifname "${name}" jump ${nftForwardChain name}
    '';

  # (Re)builds everything: idempotent table/sets first (protected dynamic
  # state -- see the header comment), then a from-scratch rebuild of the
  # derived-only nat/forward enforcement chains.
  nftSetupScript = pkgs.writeShellScript "egress-filter-nftables-setup" ''
    set -euo pipefail
    NFT=${nftBin}

    $NFT add table inet ${nftTable}

    ${concatStringsSep "\n" (mapAttrsToList nftEnsureSetsCommands enabledInterfaces)}
    ${concatStringsSep "\n" (mapAttrsToList nftStaticIpsCommands enabledInterfaces)}

    $NFT delete chain inet ${nftTable} nat_dispatch 2>/dev/null || true
    $NFT add chain inet ${nftTable} nat_dispatch '{ type nat hook prerouting priority dstnat - 10 ; }'
    ${concatStringsSep "\n" (mapAttrsToList nftNatCommands enabledInterfaces)}

    $NFT delete chain inet ${nftTable} fwd_dispatch 2>/dev/null || true
    ${concatStringsSep "\n" (mapAttrsToList nftDeleteForwardChainCommand enabledInterfaces)}
    ${concatStringsSep "\n" (mapAttrsToList nftForwardChainCommands enabledInterfaces)}
    $NFT add chain inet ${nftTable} fwd_dispatch '{ type filter hook forward priority filter - 10 ; }'
    ${concatStringsSep "\n" (mapAttrsToList nftDispatchCommand enabledInterfaces)}
  '';

  # Removes only the enforcement chains -- the table and its allow-sets
  # (dynamic state) are left alone, exactly like fail2ban-ipset-geoip-
  # cloudflare's teardown only touching its enforcing "input" chain. This
  # unit can restart (e.g. because its own script content changed across
  # a redeploy) without ever wiping the resolve-mode/dnsmasq-populated
  # entries.
  nftTeardownScript = pkgs.writeShellScript "egress-filter-nftables-teardown" ''
    ${nftBin} delete chain inet ${nftTable} fwd_dispatch 2>/dev/null || true
    ${concatStringsSep "\n" (mapAttrsToList nftDeleteForwardChainCommand enabledInterfaces)}
    ${nftBin} delete chain inet ${nftTable} nat_dispatch 2>/dev/null || true
  '';

  enabledInterfaces = filterAttrs (name: iface: iface.enable) cfg.interfaces;

  resolveInterfaces = filterAttrs (name: iface: iface.mode == "resolve") enabledInterfaces;

  dnsmasqInterfaces = filterAttrs (name: iface: iface.mode == "dnsmasq") enabledInterfaces;

in
{
  options.services.egressFilter = {
    enable = mkOption {
      type = types.bool;
      default = false;
      description = "Enable DNS-based egress filtering";
    };

    updateInterval = mkOption {
      type = types.str;
      default = "5m";
      description = ''
        How often to re-resolve domains and update the ipsets (resolve mode only).
        Uses systemd calendar format.
      '';
    };

    interfaces = mkOption {
      type = types.attrsOf (types.submodule interfaceOptions);
      default = { };
      example = literalExpression ''
        {
          virbr0 = {
            enable = true;
            mode = "resolve";
            domains = [ "example.com" ];
            allowedIPs = [ "8.8.8.8" ];
          };

          virbr1 = {
            enable = true;
            mode = "dnsmasq";
            domains = [
              "example.com"
              "cdn.example.org"
            ];
            allowedIPs = [ "8.8.8.8" "2606:4700:4700::1111" ];
            dnsmasq.listenAddress = "192.168.100.1";
          };
        }
      '';
      description = "Per-interface egress filtering rules";
    };
  };

  config = mkIf (cfg.enable && enabledInterfaces != { }) {
    # No blanket "nftables unsupported" assertion any more: the nftables
    # backend now has a native implementation (see the big comment above
    # `enabledInterfaces`) covering every mode -- resolve, dnsmasq,
    # dnsmasq+redirectDNS, static IPs, private-network passthrough, and
    # IPv6 -- so there is no remaining path that needs one. The two
    # residual gaps that couldn't be fully closed (networking.nftables.
    # flushRuleset, and no table cleanup when the feature is disabled
    # outright) are flagged below / in the README instead of blocked on.
    warnings = optional (nft && (config.networking.nftables.flushRuleset or false)) ''
      services.egressFilter is enabled on the nftables backend, and
      networking.nftables.flushRuleset is true. Every start or reload of
      nftables.service (including ones triggered by unrelated firewall
      config elsewhere on this host) runs `flush ruleset`, which wipes
      this module's self-managed "egress-filter" table -- including its
      dynamic allow-sets (the IPs the resolve-mode timer or the dnsmasq
      interceptor already learned) -- back to empty. Between that flush
      and this module's own systemd unit re-running, the affected
      interfaces are NOT filtered at all (fail-open, not fail-closed).
      See this module's README ("nftables backend") for detail; consider
      setting networking.nftables.flushRuleset = false if nothing else on
      this host depends on it.
    '';

    networking.firewall.enable = true;

    environment.systemPackages = [
      pkgs.ipset
      pkgs.iptables
      pkgs.dnsutils
    ]
    ++ optional (dnsmasqInterfaces != { }) pkgs.dnsmasq
    ++ optional nft pkgs.nftables;

    networking.firewall.extraPackages = [ pkgs.ipset ];

    # iptables backend only -- byte-identical to the pre-nftables-port
    # version of this module. The nftables backend hard-asserts these two
    # options are empty strings, so they must resolve to "" on that
    # backend; see the egress-filter-nftables systemd unit below (built
    # from the big nftables-backend `let` block above) for its
    # replacement.
    networking.firewall.extraCommands = optionalString (!nft) ''
      ${concatStringsSep "\n" (mapAttrsToList createInitialIpsets enabledInterfaces)}

      ${concatStringsSep "\n" (mapAttrsToList generateIptablesRules enabledInterfaces)}
    '';

    networking.firewall.extraStopCommands = optionalString (!nft) ''
      ${concatStringsSep "\n" (mapAttrsToList cleanupRules enabledInterfaces)}
    '';

    # systemd.services is assembled as one merged expression (rather than a
    # separate `systemd.services.egress-filter-nftables = ...;` dotted
    # assignment alongside this) -- Nix's attrset-literal merging of
    # sibling dotted paths only works when every sibling definition is
    # itself a literal attrset the parser can descend into; the
    # mapAttrs'-built pieces below are function-call results, not
    # literals, so mixing a dotted path in would hit "attribute already
    # defined" instead of merging.
    systemd.services =
      # nftables backend only -- (re)builds the self-managed
      # "egress-filter" table: idempotent table/set creation (protected
      # dynamic state) plus a from-scratch rebuild of the derived-only
      # enforcement chains. See the big comment above `enabledInterfaces`
      # for the full design and its accepted residual gaps.
      (optionalAttrs nft {
        egress-filter-nftables = {
          description = "egress-filter nftables table (per-interface allow-sets + forward/nat enforcement chains)";
          after = [
            "network-pre.target"
            "nftables.service"
          ];
          wants = [ "network-pre.target" ];
          wantedBy = [ "multi-user.target" ];

          serviceConfig = {
            Type = "oneshot";
            RemainAfterExit = true;
            ExecStart = "${nftSetupScript}";
            ExecStop = "${nftTeardownScript}";
          };
        };
      })
      // (mapAttrs' (
        name: interface:
        nameValuePair "egress-filter-resolve-${name}" {
          description = "Resolve domains for egress filter on ${name}";
          # iptables: after firewall.service (the unit that runs
          # createInitialIpsets/generateIptablesRules). nftables: after
          # egress-filter-nftables.service instead -- there is no
          # firewall.service unit at all on that backend (it's an
          # iptables-only unit from firewall-iptables.nix), and this
          # module's table/chains come from its own unit there.
          after = [ "network-online.target" ] ++ (if nft then [ "egress-filter-nftables.service" ] else [ "firewall.service" ]);
          wants = [ "network-online.target" ];
          wantedBy = [ "multi-user.target" ];

          serviceConfig = {
            Type = "oneshot";
            ExecStart = if nft then mkNftResolveScript name interface else mkResolveScript name interface;
            RemainAfterExit = false;
          };
        }
      ) resolveInterfaces)
      // (mapAttrs' (
        name: interface:
        nameValuePair "egress-filter-dnsmasq-${name}" {
          description = "Dnsmasq DNS interceptor for egress filter on ${name}";
          after = [ "network-online.target" ] ++ (if nft then [ "egress-filter-nftables.service" ] else [ "firewall.service" ]);
          wants = [ "network-online.target" ];
          wantedBy = [ "multi-user.target" ];

          preStart =
            if nft then
              nftEnsureSetsCommands name interface
            else
              ''
                ${ipset} create -exist ${ipsetName name} hash:net family inet hashsize 1024 maxelem 65536
                ${optionalString ipv6Enabled ''
                  ${ipset} create -exist ${ipsetName6 name} hash:net family inet6 hashsize 1024 maxelem 65536
                ''}
              '';

          serviceConfig = {
            Type = "simple";
            ExecStart = "${pkgs.dnsmasq}/bin/dnsmasq -k -C ${mkDnsmasqConfig name interface}";
            ExecStartPost = if nft then mkNftStaticIpsScript name interface else mkStaticIpsScript name interface;
            Restart = "on-failure";
            RestartSec = "5s";
          };
        }
      ) dnsmasqInterfaces);

    systemd.timers = mapAttrs' (
      name: interface:
      nameValuePair "egress-filter-resolve-${name}" {
        description = "Periodically resolve domains for egress filter on ${name}";
        wantedBy = [ "timers.target" ];

        timerConfig = {
          OnBootSec = "1m";
          OnUnitActiveSec = cfg.updateInterval;
          RandomizedDelaySec = "30s";
        };
      }
    ) resolveInterfaces;
  };
}