diff --git a/machines/control/configuration.nix b/machines/control/configuration.nix index b0d234b..88455cc 100644 --- a/machines/control/configuration.nix +++ b/machines/control/configuration.nix @@ -1,8 +1,46 @@ +let + world = [ "0.0.0.0/0" "::/0" ]; + + zerotier = { + direction = "in"; + protocol = "udp"; + port = "9993"; + source_ips = world; + description = "ZeroTier"; + }; + + ping = { + direction = "in"; + protocol = "icmp"; + source_ips = world; + description = "ICMP (ping / PMTUD)"; + }; + + dnsRules = [ + { direction = "in"; protocol = "udp"; port = "53"; source_ips = world; description = "DNS (UDP)"; } + { direction = "in"; protocol = "tcp"; port = "53"; source_ips = world; description = "DNS (TCP)"; } + zerotier + ping + ]; +in { imports = [ - + ../../modules/hetzner-firewall.nix ]; time.timeZone = "Etc/GMT-3"; # UTC+3 (fixed offset, no DST) services.timesyncd.enable = true; + + # Public Hetzner Cloud firewalls, kept in sync from this config on every + # deploy. Admin SSH is intentionally not exposed publicly: it rides the + # ZeroTier mesh (inside UDP 9993), with emergency-access as the console + # fallback if the mesh is ever unreachable. + cnx.hetznerFirewall = { + enable = true; + firewalls = { + "clan-control" = [ zerotier ping ]; + "clan-ns1" = dnsRules; + "clan-ns2" = dnsRules; + }; + }; } diff --git a/modules/hetzner-firewall.nix b/modules/hetzner-firewall.nix new file mode 100644 index 0000000..2a8e995 --- /dev/null +++ b/modules/hetzner-firewall.nix @@ -0,0 +1,98 @@ +{ 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 + ''; + }; +}