feat: Complete Keycloak OIDC integration for Headplane

- Added Keycloak service with PostgreSQL backend
- Configured OIDC for both Headscale and Headplane
- Added systemd service to auto-create /var/lib/headplane directory
- Updated Keycloak realm JSON with required client scopes (openid, profile, email)
- Generated and configured Headscale API key for Headplane OIDC
- Added production hardening: auto-restart, garbage collection, boot cleanup

The setup now supports:
- User login via Keycloak OIDC at https://auth.kennys.mom
- Headplane web UI with SSO at https://headplane.kennys.mom/admin
- Fallback API key authentication
- Automated secret generation and permissions management
This commit is contained in:
David Gillespie 2025-12-05 17:37:53 -07:00
parent f5d2a51f4d
commit 7c1bdb2c54
7 changed files with 348 additions and 3 deletions

54
backup-monitoring.nix Normal file
View File

@ -0,0 +1,54 @@
{ config, pkgs, lib, ... }:
{
# Database backup systemd timer
systemd.services.headscale-backup = {
description = "Backup Headscale database";
serviceConfig = {
Type = "oneshot";
User = "root";
};
script = ''
backup_dir="/var/backups/headscale"
mkdir -p $backup_dir
timestamp=$(date +%Y%m%d_%H%M%S)
${pkgs.sqlite}/bin/sqlite3 /var/lib/headscale/db.sqlite ".backup '$backup_dir/headscale_$timestamp.sqlite'"
# Keep only last 7 backups
cd $backup_dir && ls -t | tail -n +8 | xargs -r rm
echo "Backup completed: $backup_dir/headscale_$timestamp.sqlite"
'';
};
systemd.timers.headscale-backup = {
description = "Daily Headscale database backup timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
};
# /boot disk space monitoring
systemd.services.boot-disk-check = {
description = "Check /boot disk space";
serviceConfig = {
Type = "oneshot";
};
script = ''
usage=$(${pkgs.coreutils}/bin/df /boot | ${pkgs.gawk}/bin/awk 'NR==2 {print $5}' | ${pkgs.gnused}/bin/sed 's/%//')
if [ "$usage" -gt 80 ]; then
echo "WARNING: /boot is $usage% full!"
# Log to journal for monitoring
${pkgs.systemd}/bin/systemd-cat -t boot-disk-check -p warning echo "/boot disk usage is at $usage%"
fi
'';
};
systemd.timers.boot-disk-check = {
description = "Check /boot disk space daily";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
};
}

View File

@ -34,14 +34,25 @@
server = {
host = "127.0.0.1";
port = 3000;
cookie_secret = "iQ0bUyaFgwaijWaSyZ1ILA9RwfywrbZ3";
cookie_secure = false;
base_url = "https://headplane.kennys.mom";
cookie_secret = "YgNMqokyzieAMLD5ASHWCgs9vBB07kxQ";
cookie_secure = true;
};
headscale = {
url = "https://headscale.kennys.mom";
config_path = "/etc/headscale-strict.yml";
config_strict = true;
};
oidc = {
enabled = true;
issuer = "https://auth.kennys.mom/realms/headscale";
client_id = "headplane";
client_secret = "4MESLzCyNdSo91QH9hMtSMtpZgazAqtw";
redirect_uri = "https://headplane.kennys.mom/admin/oidc/callback";
token_endpoint_auth_method = "client_secret_post";
disable_api_key_login = false;
headscale_api_key = "M65sVHh.3PeukofDrstuoIcDGhFaCuskZcMIW9CD";
};
};
agent.enable = false;
};
@ -61,6 +72,34 @@
};
};
# Create headplane data directory
systemd.services.headplane-setup = {
description = "Create Headplane data directory";
wantedBy = [ "multi-user.target" ];
before = [ "headplane.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/headplane
chmod 777 /var/lib/headplane
if [ ! -f /var/lib/headplane/users.json ]; then
echo "{}" > /var/lib/headplane/users.json
chmod 666 /var/lib/headplane/users.json
fi
'';
};
# Automatic cleanup to prevent /boot from filling up
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 30d";
};
boot.loader.grub.configurationLimit = 10;
system.stateVersion = "23.11";

View File

@ -14,6 +14,10 @@
./configuration.nix
./hardware-configuration.nix
./headscale.nix
./backup-monitoring.nix
./keycloak.nix
./oidc-secret.nix
# ./oidc.nix # Disabled - using Keycloak instead
headplane.nixosModules.headplane
({ pkgs, ... }: {
nixpkgs.overlays = [

View File

@ -40,8 +40,9 @@ in
enabled = false;
};
log = {
level = "warn";
level = "info";
};
node_update_check_interval = "10s";
derp.server = {
enable = true;
region_id = 999;
@ -54,6 +55,12 @@ in
grpc_listen_addr = "127.0.0.1:50443"; # Required for Headplane communication
api_key_path = "/etc/headscale/apikey";
policy.mode = "database";
oidc = {
issuer = "https://auth.kennys.mom/realms/headscale";
client_id = "headplane";
client_secret_path = "/var/lib/headscale/oidc_client_secret";
strip_email_domain = true;
};
};
};
# Put strict config as file for headplane

View File

@ -0,0 +1,128 @@
{
"id": "headscale",
"realm": "headscale",
"displayName": "Headscale",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 5,
"defaultSignatureAlgorithm": "RS256",
"offlineSessionMaxLifespanEnabled": false,
"offlineSessionMaxLifespan": 5184000,
"clients": [
{
"clientId": "headplane",
"name": "Headplane Web UI",
"description": "Headscale web administration interface",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "**************",
"redirectUris": [
"https://headplane.kennys.mom/*"
],
"webOrigins": [
"https://headplane.kennys.mom"
],
"protocol": "openid-connect",
"attributes": {},
"fullScopeAllowed": true,
"publicClient": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": true,
"alwaysDisplayInConsole": false,
"rootUrl": "https://headplane.kennys.mom",
"baseUrl": "/admin"
}
],
"clientScopes": [
{
"name": "openid",
"protocol": "openid-connect",
"attributes": {},
"protocolMappers": []
},
{
"name": "profile",
"protocol": "openid-connect",
"attributes": {},
"protocolMappers": [
{
"name": "username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
}
]
},
{
"name": "email",
"protocol": "openid-connect",
"attributes": {},
"protocolMappers": [
{
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String"
}
}
]
}
],
"defaultDefaultClientScopes": [
"role_list",
"profile",
"email",
"roles",
"web-origins",
"acr"
],
"defaultOptionalClientScopes": [
"offline_access",
"address",
"phone",
"microprofile-jwt"
],
"users": [],
"roles": {
"realm": [
{
"name": "user",
"description": "Standard Headscale user"
},
{
"name": "admin",
"description": "Headscale administrator"
}
]
}
}

90
keycloak.nix Normal file
View File

@ -0,0 +1,90 @@
{ config, pkgs, lib, ... }:
let
domain = "kennys.mom";
authDomain = "auth.${domain}";
in
{
# PostgreSQL for Keycloak
services.postgresql = {
enable = true;
ensureDatabases = [ "keycloak" ];
ensureUsers = [{
name = "keycloak";
ensureDBOwnership = true;
}];
};
# Keycloak service
services.keycloak = {
enable = true;
database = {
type = "postgresql";
username = "keycloak";
name = "keycloak";
host = "localhost";
createLocally = false;
passwordFile = "/var/lib/keycloak-credentials/db-password";
};
settings = {
hostname = authDomain;
http-host = "127.0.0.1";
http-port = 8080;
http-enabled = true;
proxy-headers = "xforwarded";
hostname-strict-https = false;
};
};
# Generate database password and use LoadCredential
systemd.services.keycloak = {
serviceConfig = {
LoadCredential = [ "db-password:/var/lib/keycloak-credentials/db-password" ];
TimeoutStartSec = "5min";
};
};
systemd.services.keycloak-db-password = {
description = "Generate Keycloak database password";
wantedBy = [ "multi-user.target" ];
before = [ "keycloak.service" ];
after = [ "postgresql.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/keycloak-credentials
if [ ! -f /var/lib/keycloak-credentials/db-password ]; then
echo "Generating Keycloak database password..."
DB_PASS=$(${pkgs.openssl}/bin/openssl rand -base64 32)
echo -n "$DB_PASS" > /var/lib/keycloak-credentials/db-password
chmod 600 /var/lib/keycloak-credentials/db-password
# Set the password in PostgreSQL
${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/psql -c "ALTER USER keycloak WITH PASSWORD '$DB_PASS';"
echo "Database password set"
fi
'';
};
# Ngin configuration for Keycloak
services.nginx.virtualHosts."${authDomain}" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
'';
};
};
}

23
oidc-secret.nix Normal file
View File

@ -0,0 +1,23 @@
{ config, pkgs, lib, ... }:
{
# Store Keycloak client secret
systemd.services.headscale-oidc-secret = {
description = "Create Headscale OIDC client secret";
wantedBy = [ "multi-user.target" ];
before = [ "headscale.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/headscale
if [ ! -f /var/lib/headscale/oidc_client_secret ]; then
echo -n "4MESLzCyNdSo91QH9hMtSMtpZgazAqtw" > /var/lib/headscale/oidc_client_secret
echo "OIDC client secret created"
fi
# Always fix permissions
chmod 640 /var/lib/headscale/oidc_client_secret
chown headscale:headscale /var/lib/headscale/oidc_client_secret
'';
};
}