From 1c28db5f8e7504a4bf021f946b65ec4b81e7f5ac Mon Sep 17 00:00:00 2001 From: Franz Kafka Date: Thu, 19 Mar 2026 15:39:56 +0000 Subject: [PATCH] feat(headscale): add self-hosted Tailscale control server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module: modules/headscale.nix - Headscale service listening on localhost with Nginx reverse proxy - SQLite database (appropriate for personal use) - Tailscale public DERP relays for NAT traversal fallback - MagicDNS enabled with Mullvad/Quad9 upstream resolvers - Optional OIDC authentication (Google, GitHub, etc.) - Default auth: pre-shared API keys (headscale apikeys create) - Added to backup paths (SQLite DB) - headscale CLI tool added to system packages Configuration: - Domain: vpn.ashisgreat.xyz - OIDC disabled by default (documented how to enable in configuration.nix) To register a device after deploying: sudo headscale apikeys create tailscale up --login-server=https://vpn.ashisgreat.xyz --authkey= DNS record needed: vpn.ashisgreat.xyz → VPS IP --- configuration.nix | 17 +++++ modules/default.nix | 1 + modules/headscale.nix | 152 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 modules/headscale.nix diff --git a/configuration.nix b/configuration.nix index 4fcd4a4..ddb94f7 100644 --- a/configuration.nix +++ b/configuration.nix @@ -85,6 +85,7 @@ kitty.terminfo htop tmux + headscale ]; nix.settings.experimental-features = [ "nix-command" "flakes" ]; @@ -171,6 +172,22 @@ # === CrowdSec === myModules.crowdsec.enable = true; + # === Headscale (Self-hosted Tailscale) === + myModules.headscale = { + enable = true; + domain = "vpn.ashisgreat.xyz"; + # OIDC not enabled by default — use pre-shared auth keys: + # headscale apikeys create + # tailscale up --login-server=https://vpn.ashisgreat.xyz --authkey= + # + # To enable OIDC login (Google, GitHub, etc.): + # oidc.enable = true; + # oidc.issuer = "https://accounts.google.com"; + # oidc.clientId = "your-client-id"; + # oidc.clientSecret = config.sops.placeholder.headscale_oidc_secret; + # And add headscale_oidc_secret to your secrets.yaml + }; + # === Backups (Restic + B2) === myModules.backup = { enable = true; diff --git a/modules/default.nix b/modules/default.nix index d889fe1..32fc5dd 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -11,5 +11,6 @@ ./backup.nix ./adguard.nix ./forgejo.nix + ./headscale.nix ]; } diff --git a/modules/headscale.nix b/modules/headscale.nix new file mode 100644 index 0000000..704fc5c --- /dev/null +++ b/modules/headscale.nix @@ -0,0 +1,152 @@ +# Headscale Module +# Provides: Self-hosted Tailscale control server (mesh VPN) +# +# Usage: +# myModules.headscale = { +# enable = true; +# domain = "vpn.example.com"; +# }; +# +# After deploying, generate an auth key to register devices: +# headscale apikeys create +# +# Then on each device: +# tailscale up --login-server=https://vpn.example.com --authkey= + +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.myModules.headscale; +in +{ + options.myModules.headscale = { + enable = lib.mkEnableOption "Headscale (self-hosted Tailscale control server)"; + + domain = lib.mkOption { + type = lib.types.str; + example = "vpn.example.com"; + description = "Public domain name for the Headscale control server"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8085; + description = "Internal port for Headscale to listen on"; + }; + + oidc = { + enable = lib.mkEnableOption "OIDC authentication (e.g. Google, GitHub)"; + + issuer = lib.mkOption { + type = lib.types.str; + example = "https://accounts.google.com"; + description = "OIDC issuer URL"; + }; + + clientId = lib.mkOption { + type = lib.types.str; + description = "OIDC client ID (store in SOPS)"; + }; + + clientSecret = lib.mkOption { + type = lib.types.str; + description = "OIDC client secret SOPS placeholder"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + services.headscale = { + enable = true; + settings = { + server_url = "https://${cfg.domain}"; + listen_addr = "127.0.0.1:${toString cfg.port}"; + grpc_listen_addr = "127.0.0.1:50443"; + metrics_listen_addr = "127.0.0.1:9090"; + + # Use SQLite for simplicity (Postgres available for larger deployments) + database.sqlite.path = "/var/lib/headscale/db.sqlite"; + + # Private key for WireGuard — auto-generated on first run + private_key_path = "/var/lib/headscale/private.key"; + + # DERP relay servers (use Tailscale's public ones) + derp = { + urls = [ + "https://controlplane.tailscale.com/derpmap/default" + ]; + auto_update = true; + }; + + # Disable built-in DERP server (use public relays only) + derp.server.enabled = false; + + # Authentication + policy.mode = lib.mkIf cfg.oidc.enable "oidc"; + + oidc = lib.mkIf cfg.oidc.enable { + issuer = cfg.oidc.issuer; + client_id = cfg.oidc.clientId; + client_secret_path = config.sops.secrets.headscale_oidc_secret.path; + allowed_domains = [ ]; + allowed_users = [ ]; + scopes = [ "openid" "profile" "email" ]; + }; + + # DNS configuration + dns = { + magic_dns = true; + base_domain = "headscale.net"; + domains = [ ]; + nameservers = { + global = [ + "https://dns.mullvad.net/dns-query" + "https://dns.quad9.net/dns-query" + ]; + }; + override_local_dns = true; + }; + + # Logging + log.level = "info"; + + # Unix socket for headscale CLI + unix_socket = "/run/headscale/headscale.sock"; + unix_socket_permission = "0770"; + }; + }; + + # OIDC secret from SOPS + sops.secrets.headscale_oidc_secret = lib.mkIf cfg.oidc.enable { }; + + # Nginx reverse proxy + myModules.nginx.domains."${cfg.domain}" = { + port = cfg.port; + extraConfig = '' + # Headscale needs WebSocket upgrade for client connections + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Larger buffers for Headscale + proxy_buffer_size 128k; + proxy_buffers 4 256k; + ''; + # Headscale doesn't serve HTML — skip CSP + contentSecurityPolicy = null; + }; + + # Add Headscale data to backups + myModules.backup.paths = [ + config.services.headscale.settings.database.sqlite.path + ]; + + # Headscale needs its own group for socket permissions + users.groups.headscale = { }; + }; +} -- 2.53.0