Commit cd19ec4

HPCesia <me@hpcesia.com>
2026-07-21 14:15:54
Make headscale config and DNS records runtime-mutable via yq-go merge
Use ExecStartPre with yq-go to deep-merge Nix-managed settings into mutable state files under /var/lib/headscale/ on every startup: - config.yaml: deep-merge of Nix YAML into writable config - dns-records.json: merge of Nix tailscale DNS records into writable JSON array via extra_records_path Web UI changes to non-Nix-managed keys persist across restarts; Nix-declared values always take precedence. Assisted-by: opencode:deepseek-v4-pro
1 parent 12278a0
Changed files (1)
modules
services
modules/services/headscale.nix
@@ -81,6 +81,8 @@
             (builtins.attrNames r.value)
         )
         reverseProxyTailscale;
+
+      dnsRecordsFile = "/var/lib/headscale/dns-records.json";
     in {
       services.headscale = {
         enable = true;
@@ -121,17 +123,63 @@
               "2400:3200::1" # AliDNS
               "2606:4700:4700::1111" # Cloudflare
             ];
-            extra_records = tailscaleRecords;
+            extra_records_path = dnsRecordsFile;
           };
         };
       };
 
       networking.firewall.allowedUDPPorts = lib.optional (cfg.derp.enable) cfg.derp.port;
 
-      systemd.services.headscale = {
-        serviceConfig.EnvironmentFile =
-          lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
-          config.vaultix.templates.headscale-env.path;
+      systemd.services.headscale = let
+        mutableConfig = "/var/lib/headscale/config.yaml";
+        nixConfig = config.services.headscale.configFile;
+        hsCfg = config.services.headscale;
+
+        nixDnsRecords =
+          pkgs.writeText "headscale-dns-records.json"
+          (builtins.toJSON tailscaleRecords);
+
+        mergeHeadscaleState = pkgs.writeShellScript "merge-headscale-state" ''
+          set -euo pipefail
+
+          # Merge main configuration
+          if [ -f "${mutableConfig}" ]; then
+            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ({}; . * $item)' \
+              "${mutableConfig}" \
+              "${nixConfig}" \
+              > "${mutableConfig}.tmp" \
+              && mv "${mutableConfig}.tmp" "${mutableConfig}"
+          else
+            cp "${nixConfig}" "${mutableConfig}"
+            chmod 0640 "${mutableConfig}"
+          fi
+
+          # Merge DNS extra records
+          if [ -f "${dnsRecordsFile}" ]; then
+            ${lib.getExe pkgs.yq-go} eval-all '. as $item ireduce ([]; . + $item) | unique_by(.name + "|" + .type)' \
+              "${nixDnsRecords}" \
+              "${dnsRecordsFile}" \
+              > "${dnsRecordsFile}.tmp" \
+              && mv "${dnsRecordsFile}.tmp" "${dnsRecordsFile}"
+          else
+            cp "${nixDnsRecords}" "${dnsRecordsFile}"
+            chmod 0640 "${dnsRecordsFile}"
+          fi
+        '';
+      in {
+        serviceConfig = {
+          ExecStartPre = [mergeHeadscaleState];
+          EnvironmentFile =
+            lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)
+            config.vaultix.templates.headscale-env.path;
+        };
+
+        script = lib.mkForce ''
+          ${lib.optionalString (hsCfg.settings.database.postgres.password_file != null) ''
+            export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg hsCfg.settings.database.postgres.password_file})"
+          ''}
+          exec ${lib.getExe hsCfg.package} serve --config ${mutableConfig}
+        '';
       };
       vaultix.templates.headscale-env =
         lib.mkIf (hostAddr.ipv4.secret.name != null || hostAddr.ipv6.secret.name != null)