diff --git a/servers/hetzner/modules/services/caddy.nix b/servers/hetzner/modules/services/caddy.nix index 13d6877..c5b6c91 100644 --- a/servers/hetzner/modules/services/caddy.nix +++ b/servers/hetzner/modules/services/caddy.nix @@ -31,7 +31,7 @@ in { # WireGuard VPN. Gitea reaches this from the server when publishing a # release, and VPN clients can download from it; the public cannot. (release_gate) { - @deny not remote_ip 127.0.0.1 ::1 10.8.0.0/24 49.13.92.205 2a01:4f8:c014:2585::1 + @deny not remote_ip 127.0.0.1 ::1 10.8.0.0/24 fd10:8::/64 49.13.92.205 2a01:4f8:c014:2585::1 respond @deny "Forbidden" 403 } # Security headers applied to every response of every site that @@ -252,10 +252,13 @@ in { "releases.severijnse.eu" = { extraConfig = '' import security_headers - import release_gate root * /srv/releases file_server browse encode zstd gzip + + handle /private* { + import release_gate + } ''; }; diff --git a/servers/hetzner/modules/services/wireguard.nix b/servers/hetzner/modules/services/wireguard.nix index d7128a7..9a86941 100644 --- a/servers/hetzner/modules/services/wireguard.nix +++ b/servers/hetzner/modules/services/wireguard.nix @@ -1,16 +1,40 @@ -{...}: { +{pkgs, ...}: let + # sops-encrypted secrets (single file holds all service secrets), same as backup.nix. + secretsFile = ../../secrets/secrets.yaml; + # Root-only env file wg-easy reads the admin password from (0600 root). + wgEnvFile = "/var/lib/wg-easy/environment"; + + # Materialize the wg-easy admin password from sops into a root-only env file, + # so the secret never lands in the Nix store. + writeSecrets = pkgs.writeShellScript "wg-easy-write-secrets" '' + set -euo pipefail + mkdir -p "$(dirname ${wgEnvFile})" + PASSWORD="$(${pkgs.sops}/bin/sops \ + --decrypt --extract '["wg_admin_password"]' \ + --input-type yaml --output-type yaml ${secretsFile} | tr -d '\n')" + printf 'INIT_PASSWORD=%s\n' "$PASSWORD" > "${wgEnvFile}" + chmod 0600 "${wgEnvFile}" + ''; +in { + boot.kernelModules = ["ip6table_nat"]; + virtualisation.oci-containers.containers.wg-easy = { - image = "ghcr.io/wg-easy/wg-easy:latest"; + image = "ghcr.io/wg-easy/wg-easy:15"; autoStart = true; volumes = [ "/home/admin/config:/etc/wireguard:Z" ]; + environmentFiles = [wgEnvFile]; environment = { - WG_HOST = "severijnse.eu"; - PASSWORD_HASH = "$2a$14$f6l9jto2Uwn9hNudNo7cHeq08M8UDYzrUiOofWSH522QDRhgTlddC"; - WG_DEFAULT_ADDRESS = "10.8.0.x"; - WG_DEFAULT_DNS = "1.1.1.1"; - WG_PORT = "51820"; + INSECURE = "true"; + INIT_ENABLED = "true"; + INIT_USERNAME = "admin"; + INIT_HOST = "severijnse.eu"; + INIT_PORT = "51820"; + INIT_DNS = "1.1.1.1,2606:4700:4700::1111"; + INIT_IPV4_CIDR = "10.8.0.0/24"; + INIT_IPV6_CIDR = "fd10:8::/64"; + INIT_ALLOWED_IPS = "0.0.0.0/0, ::/0"; }; extraOptions = [ "--cap-add=NET_ADMIN" @@ -19,4 +43,38 @@ "--network=host" ]; }; + + # Materialize the wg-easy admin password from sops before the container starts. + systemd.services.wg-easy-secrets = { + description = "Materialize wg-easy admin password from sops"; + wantedBy = ["multi-user.target"]; + # The age key lives in /etc/age/keys.txt; the service must know where it is + # and needs a HOME for age to report its user config directory. + environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt"; + serviceConfig = { + Type = "oneshot"; + Environment = ["HOME=/root"]; + ExecStart = "${writeSecrets}"; + }; + }; + + systemd.services."podman-wg-easy" = { + requires = ["wg-easy-secrets.service"]; + after = ["wg-easy-secrets.service"]; + }; + + systemd.services.wg-nat66 = { + description = "NAT66 for WireGuard IPv6"; + after = ["network.target" "podman-wg-easy.service"]; + wants = ["podman-wg-easy.service"]; + wantedBy = ["multi-user.target"]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + ${pkgs.iptables}/bin/ip6tables -t nat -C POSTROUTING -s fd10:8::/64 -o enp1s0 -j MASQUERADE 2>/dev/null || \ + ${pkgs.iptables}/bin/ip6tables -t nat -A POSTROUTING -s fd10:8::/64 -o enp1s0 -j MASQUERADE + ''; + }; } diff --git a/servers/hetzner/secrets/secrets.yaml b/servers/hetzner/secrets/secrets.yaml index 6e0db83..4526a7f 100644 --- a/servers/hetzner/secrets/secrets.yaml +++ b/servers/hetzner/secrets/secrets.yaml @@ -2,6 +2,7 @@ restic_password: ENC[AES256_GCM,data:OHQlxUpNnTqMQm6A/o3ID/3F91NpVKOrsFYdLqrtI4v b2_key_id: ENC[AES256_GCM,data:4B9rvg06baH6aNiT,iv:Qk1ToF3lMYLTrZdzpfaoGVzdiKYs492w9fzn4/TbNfQ=,tag:djxfqwgNZmRGqvteTX9G3w==,type:str] b2_application_key: ENC[AES256_GCM,data:xGAbBqx+6ErK7sy3FR0yza6mJU5oc5uQQGcwKtcPp1Ef4XEVd8do/wN7,iv:CojcoykDRBfvN8yqMMrPJq9mtAwxyswqXXVBKPupxDs=,tag:BkfFHAGJvtCDUpFoxXVP3Q==,type:str] stalwart_admin_hash: ENC[AES256_GCM,data:GuSL/4dVdAsPDOqzvBv/rQ/TGrKU1Enc5MEQ17R8gHdsVH7ujIHxQYMu+aSAtKW0Le/hqNT+2TwOEXPsRt2ZQO/2Ks+hg7cN0mPGg8PRIN7v1wwrQpxVBNfyuX/HzwzLW/Plxc8g4Cmf8g==,iv:HwhafxB9ek9WnA76EJ04iaLZHJ72b4PtbJYOI1eFJcU=,tag:w1M+7KWOVkPZtyyE2Uv/Zg==,type:str] +wg_admin_password: ENC[AES256_GCM,data:X/+YJoNoFFU7P/2HCpTI,iv:FXDS3xuFB9jxjpQhsX2EKn0YFVzL23c2sZO6RqpKTiw=,tag:ccyQVzqNc1HEY0rxJSLlKw==,type:str] sops: kms: [] gcp_kms: [] @@ -35,8 +36,8 @@ sops: MHJrVVpDYWdJNmxtUkozSzR4Nmt3R28KrhYi830HUFAPfg8WvPad7BAuNe1mYOWt WEFIquuX/H/N+y/7uQcBDbvnBzyropE1hW8aNrxSKMeawvQZWNXkZA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-05T20:44:15Z" - mac: ENC[AES256_GCM,data:WIs44leXvMTFx2WBaUz2in2Cj0+nfjJ+wGD9Qxw6sLjfJkWZKKpEkyoajR6dEVenPKBVYCmNQ5AZKV6XA1ch3CpBObeig4aNpc4T9YFuT9avj3P8mFp3iA6ecpy/uwiFY8F6aP5D6/tgwDm6JNSu7K48CHLOTbyx3EMJCc6J1Wk=,iv:yDkBVTgMdaS6pfGyaf7LbkBwKbrmLjcntHhFlSYOvBI=,tag:G5qtf26XoWR458V5EgClNg==,type:str] + lastmodified: "2026-08-14T13:57:09Z" + mac: ENC[AES256_GCM,data:KhpVOtwk8K4ZuGm6oZ3F7IDcxcR50k6vYojGwQaqKMq1xTGvtty4Qb+ZA7ENLjUSRY28mO4lzb3Dt7iK87TjiXeDhRGDAPnWGgzmGVzkxcuvu4nBX6Bf+ZikTQLW+SiDDSqsRTfaLwTacCQd7bLz4KUzXsX3HO0Yy7NPfwNZ+Q4=,iv:EJtR1meg8bFEdXaWgwCTKFMhGJKNZHXaaKfIxWaR4ME=,tag:3RAQXuG9WBZSUUoE3s1rkw==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.8.1