# The hive's own nftables table, `inet hive_egress`, loaded by a systemd # oneshot. It is independent of the NixOS firewall: `networking.firewall` # renders only the rules of the backend in use (`iptables` unless # `networking.nftables.enable` is set), so a rule that has to hold on every # host lives here, in a table no firewall backend writes. Only # `networking.nftables.flushRuleset` removes it; the unit re-applies it then. # # The table holds two chains. `input`: traffic from the agent bridge to host # loopback is dropped. The kernel does the same by default (a packet for # 127.0.0.0/8 arriving on a non-loopback interface is a martian while # `route_localnet` is 0); this rule is the second layer, and the assertion # below keeps the first one on. `forward` (`egress.forward = "log"`): traffic # the host routes from the agent bridge to anywhere else is logged and # counted, never blocked. { lib, config, pkgs, ... }: let cfg = config.services.hyperhive.network; nft = lib.getExe pkgs.nftables; br = cfg.bridgeName; # The mesh interface (swarm-wireguard.nix): inter-hive traffic is not egress. meshInterface = "wg-hive"; # Logging is per source address, so one busy agent cannot use up the budget # of another: a burst of 20 lines, then one a second, and an address is # forgotten after a minute without a new flow. The rule accepts after it # logs. A flow the rule cannot log falls through to the unmetered rule below # it: the address was over its rate, or the set is full (`update` then fails # and the rule stops matching), which a container spoofing source addresses # can bring about. Only that case reaches the unmetered rule, so a flow # gets one line at most. The unmetered rule has one budget for all # addresses, 5 lines a second after a burst of 50, about 100 MB of journal a # day when it never rests. logRate = "1/second burst 20 packets"; unmeteredRate = "5/second burst 50 packets"; fromBridge = ''iifname "${br}" oifname != "${br}"''; logRule = family: addr: ''${fromBridge} ct state new update @log_${family} { ${addr} saddr limit rate ${logRate} } log prefix "hive-egress-would-drop " accept''; # Priority `filter - 10` runs before the NixOS firewall's forward handling, # which sits at `filter` on both backends (`FORWARD` of the iptables backend, # the `forward` chain of `nixos-fw`). An `accept` here ends this chain only, # so the chain neither allows what those refuse nor refuses anything itself. # Traffic to the host's own addresses (squid, dnsmasq, the gateway) is # delivered locally and never reaches the forward hook. forwardChain = '' set log_v4 { type ipv4_addr; flags dynamic; timeout 1m; size 4096; } set log_v6 { type ipv6_addr; flags dynamic; timeout 1m; size 4096; } chain forward { type filter hook forward priority filter - 10; policy accept; iifname "${br}" oifname "${meshInterface}" accept ${fromBridge} counter ${logRule "v4" "ip"} ${logRule "v6" "ip6"} ${fromBridge} ct state new limit rate ${unmeteredRate} log prefix "hive-egress-would-drop-unmetered " ${fromBridge} accept } ''; # One transaction: `add` + `delete` drop an old copy of the table and the # definition recreates it, so the file loads the same on first start, on a # reload and after something else flushed the ruleset. rulesetText = '' add table inet hive_egress delete table inet hive_egress table inet hive_egress { chain input { type filter hook input priority filter - 10; policy accept; iifname "${br}" ip daddr 127.0.0.0/8 counter drop iifname "${br}" ip6 daddr ::1 counter drop } ${lib.optionalString (cfg.egress.forward == "log") forwardChain} } ''; routeLocalnetKeys = [ "net.ipv4.conf.all.route_localnet" "net.ipv4.conf.default.route_localnet" "net.ipv4.conf.${cfg.bridgeName}.route_localnet" ]; isOn = v: builtins.elem v [ 1 "1" true ]; ruleset = pkgs.writeText "hive-egress.nft" cfg.egress.ruleset; in { options.services.hyperhive.network.egress.forward = lib.mkOption { type = lib.types.enum [ "accept" "log" "drop" ]; default = "log"; description = '' What the `inet hive_egress` table does with traffic the host forwards from the agent bridge to anywhere but the bridge and the mesh. - `accept`: nothing; the table has no forward chain. - `log`: each new flow is logged with the prefix `hive-egress-would-drop `, rate limited per source address, and counted. A flow the per-source limit cannot log gets a line with the prefix `hive-egress-would-drop-unmetered ` from a limit shared by all addresses. Nothing is blocked. The source address maps to an agent through the hive's DHCP leases. - `drop`: not available yet; the evaluation refuses it. ''; }; options.services.hyperhive.network.egress.ruleset = lib.mkOption { type = lib.types.lines; internal = true; readOnly = true; default = rulesetText; defaultText = lib.literalMD "the `inet hive_egress` table definition"; description = '' The nft file the `hive-egress` unit loads, as text. ''; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = cfg.egress.forward != "drop"; message = '' services.hyperhive.network.egress.forward = "drop" is not available yet: the interception that lets an agent's web traffic through squid has not landed, so a drop would cut every direct connection with no replacement. Use "log" or "accept". ''; } { assertion = !(lib.any (k: isOn (config.boot.kernel.sysctl.${k} or 0)) routeLocalnetKeys); message = '' boot.kernel.sysctl must not turn on route_localnet (${lib.concatStringsSep ", " routeLocalnetKeys}). With it on, the kernel routes a packet from the agent bridge to 127.0.0.0/8 instead of dropping it as a martian, which leaves the hive_egress table as the only barrier between an agent and the host's loopback services. ''; } ]; systemd.services.hive-egress = { description = "hive nftables table (agent bridge egress log, host loopback drop)"; wantedBy = [ "multi-user.target" ]; wants = [ "network-pre.target" ]; before = [ "network-pre.target" ]; # `nftables.service` runs `flush ruleset` where `networking.nftables.flushRuleset` # is on: start after it, follow its restarts and re-apply on its reload. after = [ "nftables.service" ]; partOf = [ "nftables.service" ]; unitConfig.ReloadPropagatedFrom = [ "nftables.service" ]; reloadIfChanged = true; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "${nft} -f ${ruleset}"; ExecReload = "${nft} -f ${ruleset}"; ExecStop = "-${nft} delete table inet hive_egress"; }; }; }; }