{ config, lib, pkgs, ... }: let cfg = config.cnx.hetznerFirewall; in { options.cnx.hetznerFirewall = { enable = lib.mkEnableOption "Hetzner Cloud firewall rule sync"; firewalls = lib.mkOption { type = lib.types.attrsOf (lib.types.listOf (lib.types.attrsOf lib.types.anything)); default = { }; example = lib.literalExpression '' { "clan-control" = [ { direction = "in"; protocol = "udp"; port = "9993"; source_ips = [ "0.0.0.0/0" "::/0" ]; } ]; } ''; description = '' Map of Hetzner Cloud firewall name to its full list of inbound rule objects, written with the Hetzner API field names (direction, protocol, port, source_ips, description). The entire rule set is replaced on each sync, so this is the single source of truth. A firewall that does not exist yet is created (unapplied); applying it to servers is done out of band. ''; }; tokenFile = lib.mkOption { type = lib.types.path; default = config.clan.core.vars.generators.hetzner-firewall.files.token.path; defaultText = lib.literalExpression "config.clan.core.vars.generators.hetzner-firewall.files.token.path"; description = "File holding the Hetzner Cloud API token (Read & Write)."; }; }; config = lib.mkIf cfg.enable { clan.core.vars.generators.hetzner-firewall = { prompts.token = { description = "Hetzner Cloud API token (Read & Write)"; type = "hidden"; persist = true; }; }; systemd.services.hetzner-firewall-sync = { description = "Sync Hetzner Cloud firewall rules from Nix config"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; path = [ pkgs.curl pkgs.jq pkgs.coreutils ]; environment.SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt"; serviceConfig = { Type = "oneshot"; LoadCredential = "token:${cfg.tokenFile}"; DynamicUser = true; ProtectSystem = "strict"; ProtectHome = true; PrivateTmp = true; NoNewPrivileges = true; }; script = '' set -euo pipefail api="https://api.hetzner.cloud/v1" # Keep the bearer token out of process argv: read it from the systemd # credential and stash it in a private (PrivateTmp) 0600 header file. hdr="$(mktemp)" printf 'Authorization: Bearer %s\n' "$(cat "$CREDENTIALS_DIRECTORY/token")" > "$hdr" hapi() { curl -fsS -H @"$hdr" -H "Content-Type: application/json" "$@" } ${lib.concatStringsSep "\n" ( lib.mapAttrsToList (fwName: rules: '' name=${lib.escapeShellArg fwName} rules=${lib.escapeShellArg (builtins.toJSON rules)} id="$(hapi "$api/firewalls?name=$name" | jq -r '.firewalls[0].id // empty')" if [ -z "$id" ]; then echo "hetzner-firewall: creating $name" jq -n --arg name "$name" --argjson rules "$rules" '{name: $name, rules: $rules}' \ | hapi -X POST --data-binary @- "$api/firewalls" > /dev/null else echo "hetzner-firewall: setting rules on $name (id $id)" jq -n --argjson rules "$rules" '{rules: $rules}' \ | hapi -X POST --data-binary @- "$api/firewalls/$id/actions/set_rules" > /dev/null fi '') cfg.firewalls )} ''; }; # Re-apply on every `clan machines update` (activation), not just at boot or # when the rule set changes. --no-block keeps it from blocking the switch; # a failed sync is logged to the journal but never fails the deploy. system.activationScripts.hetznerFirewallSync = '' ${config.systemd.package}/bin/systemctl start --no-block hetzner-firewall-sync.service || true ''; }; }