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:
parent
f5d2a51f4d
commit
7c1bdb2c54
54
backup-monitoring.nix
Normal file
54
backup-monitoring.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
128
keycloak-headscale-realm.json
Normal file
128
keycloak-headscale-realm.json
Normal 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
90
keycloak.nix
Normal 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
23
oidc-secret.nix
Normal 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
|
||||
'';
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user