nixos/headscale: modernize

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
Kristoffer Dalby 2024-09-06 12:47:36 +02:00 committed by Sandro Jäckel
parent 5f9af4dcd5
commit 5dd728a081
No known key found for this signature in database
GPG Key ID: 3AF5A43A3EECC2E5

View File

@ -1,8 +1,7 @@
{
config,
lib,
pkgs,
...
{ config
, lib
, pkgs
, ...
}:
with lib; let
cfg = config.services.headscale;
@ -10,9 +9,19 @@ with lib; let
dataDir = "/var/lib/headscale";
runDir = "/run/headscale";
settingsFormat = pkgs.formats.yaml {};
cliConfig = {
# Turn off update checks since the origin of our package
# is nixpkgs and not Github.
disable_check_updates = true;
unix_socket = "${runDir}/headscale.sock";
};
settingsFormat = pkgs.formats.yaml { };
configFile = settingsFormat.generate "headscale.yaml" cfg.settings;
in {
cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig;
in
{
options = {
services.headscale = {
enable = mkEnableOption "headscale, Open Source coordination server for Tailscale";
@ -84,14 +93,6 @@ in {
example = "https://myheadscale.example.com:443";
};
private_key_path = mkOption {
type = types.path;
default = "${dataDir}/private.key";
description = ''
Path to private key file, generated automatically if it does not exist.
'';
};
noise.private_key_path = mkOption {
type = types.path;
default = "${dataDir}/noise_private.key";
@ -100,10 +101,44 @@ in {
'';
};
prefixes =
let
prefDesc = ''
Each prefix consists of either an IPv4 or IPv6 address,
and the associated prefix length, delimited by a slash.
It must be within IP ranges supported by the Tailscale
client - i.e., subnets of 100.64.0.0/10 and fd7a:115c:a1e0::/48.
'';
in
{
v4 = mkOption {
type = types.str;
default = "100.64.0.0/10";
description = prefDesc;
};
v6 = mkOption {
type = types.str;
default = "fd7a:115c:a1e0::/48";
description = prefDesc;
};
allocation = mkOption {
type = types.enum [ "sequential" "random" ];
example = "random";
default = "sequential";
description = ''
Strategy used for allocation of IPs to nodes, available options:
- sequential (default): assigns the next free IP from the previous given IP.
- random: assigns the next free IP from a pseudo-random IP generator (crypto/rand).
'';
};
};
derp = {
urls = mkOption {
type = types.listOf types.str;
default = ["https://controlplane.tailscale.com/derpmap/default"];
default = [ "https://controlplane.tailscale.com/derpmap/default" ];
description = ''
List of urls containing DERP maps.
See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
@ -112,7 +147,7 @@ in {
paths = mkOption {
type = types.listOf types.path;
default = [];
default = [ ];
description = ''
List of file paths containing DERP maps.
See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps.
@ -136,6 +171,14 @@ in {
'';
example = "5m";
};
server.private_key_path = lib.mkOption {
type = lib.types.path;
default = "${dataDir}/derp_server_private.key";
description = ''
Path to derp private key file, generated automatically if it does not exist.
'';
};
};
ephemeral_node_inactivity_timeout = mkOption {
@ -147,102 +190,98 @@ in {
example = "5m";
};
db_type = mkOption {
type = types.enum ["sqlite3" "postgres"];
example = "postgres";
default = "sqlite3";
description = "Database engine to use.";
};
db_host = mkOption {
type = types.nullOr types.str;
default = null;
example = "127.0.0.1";
description = "Database host address.";
};
db_port = mkOption {
type = types.nullOr types.port;
default = null;
example = 3306;
description = "Database host port.";
};
db_name = mkOption {
type = types.nullOr types.str;
default = null;
example = "headscale";
description = "Database name.";
};
db_user = mkOption {
type = types.nullOr types.str;
default = null;
example = "headscale";
description = "Database user.";
};
db_password_file = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/headscale-dbpassword";
description = ''
A file containing the password corresponding to
{option}`database.user`.
'';
};
db_path = mkOption {
type = types.nullOr types.str;
default = "${dataDir}/db.sqlite";
description = "Path to the sqlite3 database file.";
};
log.level = mkOption {
type = types.str;
default = "info";
description = ''
headscale log level.
'';
example = "debug";
};
log.format = mkOption {
type = types.str;
default = "text";
description = ''
headscale log format.
'';
example = "json";
};
dns_config = {
nameservers = mkOption {
type = types.listOf types.str;
default = ["1.1.1.1"];
database = {
type = mkOption {
type = types.enum [ "sqlite" "sqlite3" "postgres" ];
example = "postgres";
default = "sqlite";
description = ''
List of nameservers to pass to Tailscale clients.
Database engine to use.
Please note that using Postgres is highly discouraged as it is only supported for legacy reasons.
All new development, testing and optimisations are done with SQLite in mind.
'';
};
override_local_dns = mkOption {
type = types.bool;
default = false;
description = ''
Whether to use [Override local DNS](https://tailscale.com/kb/1054/dns/).
'';
example = true;
sqlite = {
path = mkOption {
type = types.nullOr types.str;
default = "${dataDir}/db.sqlite";
description = "Path to the sqlite3 database file.";
};
write_ahead_log = mkOption {
type = types.bool;
default = true;
description = ''
Enable WAL mode for SQLite. This is recommended for production environments.
https://www.sqlite.org/wal.html
'';
example = true;
};
};
domains = mkOption {
type = types.listOf types.str;
default = [];
postgres = {
host = mkOption {
type = types.nullOr types.str;
default = null;
example = "127.0.0.1";
description = "Database host address.";
};
port = mkOption {
type = types.nullOr types.port;
default = null;
example = 3306;
description = "Database host port.";
};
name = mkOption {
type = types.nullOr types.str;
default = null;
example = "headscale";
description = "Database name.";
};
user = mkOption {
type = types.nullOr types.str;
default = null;
example = "headscale";
description = "Database user.";
};
password_file = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/headscale-dbpassword";
description = ''
A file containing the password corresponding to
{option}`database.user`.
'';
};
};
};
log = {
level = mkOption {
type = types.str;
default = "info";
description = ''
Search domains to inject to Tailscale clients.
headscale log level.
'';
example = ["mydomain.internal"];
example = "debug";
};
format = mkOption {
type = types.str;
default = "text";
description = ''
headscale log format.
'';
example = "json";
};
};
dns = {
magic_dns = mkOption {
type = types.bool;
default = true;
@ -264,6 +303,25 @@ in {
`myhost.mynamespace.example.com`).
'';
};
nameservers = {
global = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
List of nameservers to pass to Tailscale clients.
'';
};
};
search_domains = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Search domains to inject to Tailscale clients.
'';
example = [ "mydomain.internal" ];
};
};
oidc = {
@ -294,7 +352,7 @@ in {
scope = mkOption {
type = types.listOf types.str;
default = ["openid" "profile" "email"];
default = [ "openid" "profile" "email" ];
description = ''
Scopes used in the OIDC flow.
'';
@ -348,7 +406,7 @@ in {
};
tls_letsencrypt_challenge_type = mkOption {
type = types.enum ["TLS-ALPN-01" "HTTP-01"];
type = types.enum [ "TLS-ALPN-01" "HTTP-01" ];
default = "HTTP-01";
description = ''
Type of ACME challenge to use, currently supported types:
@ -382,12 +440,24 @@ in {
'';
};
acl_policy_path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing ACL policies.
'';
policy = {
mode = mkOption {
type = types.enum [ "file" "database" ];
default = "file";
description = ''
The mode can be "file" or "database" that defines
where the ACL policies are stored and read from.
'';
};
path = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
If the mode is set to "file", the path to a
HuJSON file containing ACL policies.
'';
};
};
};
};
@ -396,64 +466,63 @@ in {
};
imports = [
# TODO address + port = listen_addr
(mkRenamedOptionModule ["services" "headscale" "serverUrl"] ["services" "headscale" "settings" "server_url"])
(mkRenamedOptionModule ["services" "headscale" "privateKeyFile"] ["services" "headscale" "settings" "private_key_path"])
(mkRenamedOptionModule ["services" "headscale" "derp" "urls"] ["services" "headscale" "settings" "derp" "urls"])
(mkRenamedOptionModule ["services" "headscale" "derp" "paths"] ["services" "headscale" "settings" "derp" "paths"])
(mkRenamedOptionModule ["services" "headscale" "derp" "autoUpdate"] ["services" "headscale" "settings" "derp" "auto_update_enable"])
(mkRenamedOptionModule ["services" "headscale" "derp" "updateFrequency"] ["services" "headscale" "settings" "derp" "update_frequency"])
(mkRenamedOptionModule ["services" "headscale" "ephemeralNodeInactivityTimeout"] ["services" "headscale" "settings" "ephemeral_node_inactivity_timeout"])
(mkRenamedOptionModule ["services" "headscale" "database" "type"] ["services" "headscale" "settings" "db_type"])
(mkRenamedOptionModule ["services" "headscale" "database" "path"] ["services" "headscale" "settings" "db_path"])
(mkRenamedOptionModule ["services" "headscale" "database" "host"] ["services" "headscale" "settings" "db_host"])
(mkRenamedOptionModule ["services" "headscale" "database" "port"] ["services" "headscale" "settings" "db_port"])
(mkRenamedOptionModule ["services" "headscale" "database" "name"] ["services" "headscale" "settings" "db_name"])
(mkRenamedOptionModule ["services" "headscale" "database" "user"] ["services" "headscale" "settings" "db_user"])
(mkRenamedOptionModule ["services" "headscale" "database" "passwordFile"] ["services" "headscale" "settings" "db_password_file"])
(mkRenamedOptionModule ["services" "headscale" "logLevel"] ["services" "headscale" "settings" "log" "level"])
(mkRenamedOptionModule ["services" "headscale" "dns" "nameservers"] ["services" "headscale" "settings" "dns_config" "nameservers"])
(mkRenamedOptionModule ["services" "headscale" "dns" "domains"] ["services" "headscale" "settings" "dns_config" "domains"])
(mkRenamedOptionModule ["services" "headscale" "dns" "magicDns"] ["services" "headscale" "settings" "dns_config" "magic_dns"])
(mkRenamedOptionModule ["services" "headscale" "dns" "baseDomain"] ["services" "headscale" "settings" "dns_config" "base_domain"])
(mkRenamedOptionModule ["services" "headscale" "openIdConnect" "issuer"] ["services" "headscale" "settings" "oidc" "issuer"])
(mkRenamedOptionModule ["services" "headscale" "openIdConnect" "clientId"] ["services" "headscale" "settings" "oidc" "client_id"])
(mkRenamedOptionModule ["services" "headscale" "openIdConnect" "clientSecretFile"] ["services" "headscale" "settings" "oidc" "client_secret_path"])
(mkRenamedOptionModule ["services" "headscale" "tls" "letsencrypt" "hostname"] ["services" "headscale" "settings" "tls_letsencrypt_hostname"])
(mkRenamedOptionModule ["services" "headscale" "tls" "letsencrypt" "challengeType"] ["services" "headscale" "settings" "tls_letsencrypt_challenge_type"])
(mkRenamedOptionModule ["services" "headscale" "tls" "letsencrypt" "httpListen"] ["services" "headscale" "settings" "tls_letsencrypt_listen"])
(mkRenamedOptionModule ["services" "headscale" "tls" "certFile"] ["services" "headscale" "settings" "tls_cert_path"])
(mkRenamedOptionModule ["services" "headscale" "tls" "keyFile"] ["services" "headscale" "settings" "tls_key_path"])
(mkRenamedOptionModule ["services" "headscale" "aclPolicyFile"] ["services" "headscale" "settings" "acl_policy_path"])
(mkRenamedOptionModule [ "services" "headscale" "serverUrl" ] [ "services" "headscale" "settings" "server_url" ])
(mkRenamedOptionModule [ "services" "headscale" "derp" "urls" ] [ "services" "headscale" "settings" "derp" "urls" ])
(mkRenamedOptionModule [ "services" "headscale" "derp" "paths" ] [ "services" "headscale" "settings" "derp" "paths" ])
(mkRenamedOptionModule [ "services" "headscale" "derp" "autoUpdate" ] [ "services" "headscale" "settings" "derp" "auto_update_enable" ])
(mkRenamedOptionModule [ "services" "headscale" "derp" "updateFrequency" ] [ "services" "headscale" "settings" "derp" "update_frequency" ])
(mkRenamedOptionModule [ "services" "headscale" "ephemeralNodeInactivityTimeout" ] [ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ])
(mkRemovedOptionModule ["services" "headscale" "openIdConnect" "domainMap"] ''
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_type"] ["services" "headscale" "settings" "database" "type"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_path"] ["services" "headscale" "settings" "database" "sqlite" "path"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_host"] ["services" "headscale" "settings" "database" "postgres" "host"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_port"] ["services" "headscale" "settings" "database" "postgres" "port"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_name"] ["services" "headscale" "settings" "database" "postgres" "name"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_user"] ["services" "headscale" "settings" "database" "postgres" "user"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "db_password_file"] ["services" "headscale" "settings" "database" "postgres" "password_file"])
(mkRenamedOptionModule [ "services" "headscale" "logLevel" ] [ "services" "headscale" "settings" "log" "level" ])
# (mkRenamedOptionModule ["services" "headscale" "settings" "dns_config" "nameservers"] ["services" "headscale" "settings" "dns" "nameservers" "global"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "dns_config" "domains"] ["services" "headscale" "settings" "dns" "search_domains"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "dns_config" "magic_dns"] ["services" "headscale" "settings" "dns" "magic_dns"])
# (mkRenamedOptionModule ["services" "headscale" "settings" "dns_config" "base_domain"] ["services" "headscale" "settings" "dns" "base_domain"])
(mkRenamedOptionModule [ "services" "headscale" "openIdConnect" "issuer" ] [ "services" "headscale" "settings" "oidc" "issuer" ])
(mkRenamedOptionModule [ "services" "headscale" "openIdConnect" "clientId" ] [ "services" "headscale" "settings" "oidc" "client_id" ])
(mkRenamedOptionModule [ "services" "headscale" "openIdConnect" "clientSecretFile" ] [ "services" "headscale" "settings" "oidc" "client_secret_path" ])
(mkRenamedOptionModule [ "services" "headscale" "tls" "letsencrypt" "hostname" ] [ "services" "headscale" "settings" "tls_letsencrypt_hostname" ])
(mkRenamedOptionModule [ "services" "headscale" "tls" "letsencrypt" "challengeType" ] [ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ])
(mkRenamedOptionModule [ "services" "headscale" "tls" "letsencrypt" "httpListen" ] [ "services" "headscale" "settings" "tls_letsencrypt_listen" ])
(mkRenamedOptionModule [ "services" "headscale" "tls" "certFile" ] [ "services" "headscale" "settings" "tls_cert_path" ])
(mkRenamedOptionModule [ "services" "headscale" "tls" "keyFile" ] [ "services" "headscale" "settings" "tls_key_path" ])
# (mkRenamedOptionModule ["services" "headscale" "settings" "acl_policy_path"] ["services" "headscale" "settings" "policy" "path"])
(mkRemovedOptionModule [ "services" "headscale" "openIdConnect" "domainMap" ] ''
Headscale no longer uses domain_map. If you're using an old version of headscale you can still set this option via services.headscale.settings.oidc.domain_map.
'')
];
config = mkIf cfg.enable {
services.headscale.settings = {
listen_addr = mkDefault "${cfg.address}:${toString cfg.port}";
services.headscale.settings = mkMerge [
cliConfig
{
listen_addr = mkDefault "${cfg.address}:${toString cfg.port}";
# Turn off update checks since the origin of our package
# is nixpkgs and not Github.
disable_check_updates = true;
unix_socket = "${runDir}/headscale.sock";
tls_letsencrypt_cache_dir = "${dataDir}/.cache";
};
tls_letsencrypt_cache_dir = "${dataDir}/.cache";
}
];
environment = {
# Setup the headscale configuration in a known path in /etc to
# allow both the Server and the Client use it to find the socket
# for communication.
etc."headscale/config.yaml".source = configFile;
# Headscale CLI needs a minimal config to be able to locate the unix socket
# to talk to the server instance.
etc."headscale/config.yaml".source = cliConfigFile;
systemPackages = [ cfg.package ];
};
users.groups.headscale = mkIf (cfg.group == "headscale") {};
users.groups.headscale = mkIf (cfg.group == "headscale") { };
users.users.headscale = mkIf (cfg.user == "headscale") {
description = "headscale user";
@ -465,65 +534,64 @@ in {
systemd.services.headscale = {
description = "headscale coordination server for Tailscale";
wants = [ "network-online.target" ];
after = ["network-online.target"];
wantedBy = ["multi-user.target"];
restartTriggers = [configFile];
environment.GIN_MODE = "release";
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
script = ''
${optionalString (cfg.settings.db_password_file != null) ''
export HEADSCALE_DB_PASS="$(head -n1 ${escapeShellArg cfg.settings.db_password_file})"
${optionalString (cfg.settings.database.postgres.password_file != null) ''
export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${escapeShellArg cfg.settings.database.postgres.password_file})"
''}
exec ${cfg.package}/bin/headscale serve
exec ${lib.getExe cfg.package} serve --config ${configFile}
'';
serviceConfig = let
capabilityBoundingSet = ["CAP_CHOWN"] ++ optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
in {
Restart = "always";
Type = "simple";
User = cfg.user;
Group = cfg.group;
serviceConfig =
let
capabilityBoundingSet = [ "CAP_CHOWN" ] ++ optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
in
{
Restart = "always";
Type = "simple";
User = cfg.user;
Group = cfg.group;
# Hardening options
RuntimeDirectory = "headscale";
# Allow headscale group access so users can be added and use the CLI.
RuntimeDirectoryMode = "0750";
# Hardening options
RuntimeDirectory = "headscale";
# Allow headscale group access so users can be added and use the CLI.
RuntimeDirectoryMode = "0750";
StateDirectory = "headscale";
StateDirectoryMode = "0750";
StateDirectory = "headscale";
StateDirectoryMode = "0750";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
PrivateMounts = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectHostname = true;
ProtectClock = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictNamespaces = true;
RemoveIPC = true;
UMask = "0077";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
PrivateMounts = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectHostname = true;
ProtectClock = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictNamespaces = true;
RemoveIPC = true;
UMask = "0077";
CapabilityBoundingSet = capabilityBoundingSet;
AmbientCapabilities = capabilityBoundingSet;
NoNewPrivileges = true;
LockPersonality = true;
RestrictRealtime = true;
SystemCallFilter = ["@system-service" "~@privileged" "@chown"];
SystemCallArchitectures = "native";
RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
};
CapabilityBoundingSet = capabilityBoundingSet;
AmbientCapabilities = capabilityBoundingSet;
NoNewPrivileges = true;
LockPersonality = true;
RestrictRealtime = true;
SystemCallFilter = [ "@system-service" "~@privileged" "@chown" ];
SystemCallArchitectures = "native";
RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
};
};
};
meta.maintainers = with maintainers; [kradalby misterio77];
meta.maintainers = with maintainers; [ kradalby misterio77 ];
}