feat(headscale): add self-hosted Tailscale control server #5

Merged
ashie merged 2 commits from franz-kafka/nixos-vps:feat/headscale into main 2026-03-19 20:18:58 +00:00
3 changed files with 170 additions and 0 deletions

View file

@ -85,6 +85,7 @@
kitty.terminfo kitty.terminfo
htop htop
tmux tmux
headscale
]; ];
nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.experimental-features = [ "nix-command" "flakes" ];
@ -179,6 +180,22 @@
# === CrowdSec === # === CrowdSec ===
myModules.crowdsec.enable = true; 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=<key>
#
# 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) === # === Backups (Restic + B2) ===
myModules.backup = { myModules.backup = {
enable = true; enable = true;

View file

@ -11,5 +11,6 @@
./backup.nix ./backup.nix
./adguard.nix ./adguard.nix
./forgejo.nix ./forgejo.nix
./headscale.nix
]; ];
} }

152
modules/headscale.nix Normal file
View file

@ -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=<key>
{
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 = { };
};
}