{ config, lib, pkgs, ... }: with lib; let cfg = config.services.headscale; dataDir = "/var/lib/headscale"; runDir = "/run/headscale"; settingsFormat = pkgs.formats.yaml {}; configFile = settingsFormat.generate "headscale.yaml" cfg.settings; in { options = { services.headscale = { enable = mkEnableOption (lib.mdDoc "headscale, Open Source coordination server for Tailscale"); package = mkOption { type = types.package; default = pkgs.headscale; defaultText = literalExpression "pkgs.headscale"; description = lib.mdDoc '' Which headscale package to use for the running server. ''; }; user = mkOption { default = "headscale"; type = types.str; description = lib.mdDoc '' User account under which headscale runs. ::: {.note} If left as the default value this user will automatically be created on system activation, otherwise you are responsible for ensuring the user exists before the headscale service starts. ::: ''; }; group = mkOption { default = "headscale"; type = types.str; description = lib.mdDoc '' Group under which headscale runs. ::: {.note} If left as the default value this group will automatically be created on system activation, otherwise you are responsible for ensuring the user exists before the headscale service starts. ::: ''; }; address = mkOption { type = types.str; default = "127.0.0.1"; description = lib.mdDoc '' Listening address of headscale. ''; example = "0.0.0.0"; }; port = mkOption { type = types.port; default = 8080; description = lib.mdDoc '' Listening port of headscale. ''; example = 443; }; settings = mkOption { description = lib.mdDoc '' Overrides to {file}`config.yaml` as a Nix attribute set. Check the [example config](https://github.com/juanfont/headscale/blob/main/config-example.yaml) for possible options. ''; type = types.submodule { freeformType = settingsFormat.type; options = { server_url = mkOption { type = types.str; default = "http://127.0.0.1:8080"; description = lib.mdDoc '' The url clients will connect to. ''; example = "https://myheadscale.example.com:443"; }; private_key_path = mkOption { type = types.path; default = "${dataDir}/private.key"; description = lib.mdDoc '' 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"; description = lib.mdDoc '' Path to noise private key file, generated automatically if it does not exist. ''; }; derp = { urls = mkOption { type = types.listOf types.str; default = ["https://controlplane.tailscale.com/derpmap/default"]; description = lib.mdDoc '' List of urls containing DERP maps. See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. ''; }; paths = mkOption { type = types.listOf types.path; default = []; description = lib.mdDoc '' List of file paths containing DERP maps. See [How Tailscale works](https://tailscale.com/blog/how-tailscale-works/) for more information on DERP maps. ''; }; auto_update_enable = mkOption { type = types.bool; default = true; description = lib.mdDoc '' Whether to automatically update DERP maps on a set frequency. ''; example = false; }; update_frequency = mkOption { type = types.str; default = "24h"; description = lib.mdDoc '' Frequency to update DERP maps. ''; example = "5m"; }; }; ephemeral_node_inactivity_timeout = mkOption { type = types.str; default = "30m"; description = lib.mdDoc '' Time before an inactive ephemeral node is deleted. ''; example = "5m"; }; db_type = mkOption { type = types.enum ["sqlite3" "postgres"]; example = "postgres"; default = "sqlite3"; description = lib.mdDoc "Database engine to use."; }; db_host = mkOption { type = types.nullOr types.str; default = null; example = "127.0.0.1"; description = lib.mdDoc "Database host address."; }; db_port = mkOption { type = types.nullOr types.port; default = null; example = 3306; description = lib.mdDoc "Database host port."; }; db_name = mkOption { type = types.nullOr types.str; default = null; example = "headscale"; description = lib.mdDoc "Database name."; }; db_user = mkOption { type = types.nullOr types.str; default = null; example = "headscale"; description = lib.mdDoc "Database user."; }; db_password_file = mkOption { type = types.nullOr types.path; default = null; example = "/run/keys/headscale-dbpassword"; description = lib.mdDoc '' A file containing the password corresponding to {option}`database.user`. ''; }; db_path = mkOption { type = types.nullOr types.str; default = "${dataDir}/db.sqlite"; description = lib.mdDoc "Path to the sqlite3 database file."; }; log.level = mkOption { type = types.str; default = "info"; description = lib.mdDoc '' headscale log level. ''; example = "debug"; }; log.format = mkOption { type = types.str; default = "text"; description = lib.mdDoc '' headscale log format. ''; example = "json"; }; dns_config = { nameservers = mkOption { type = types.listOf types.str; default = ["1.1.1.1"]; description = lib.mdDoc '' List of nameservers to pass to Tailscale clients. ''; }; override_local_dns = mkOption { type = types.bool; default = false; description = lib.mdDoc '' Whether to use [Override local DNS](https://tailscale.com/kb/1054/dns/). ''; example = true; }; domains = mkOption { type = types.listOf types.str; default = []; description = lib.mdDoc '' Search domains to inject to Tailscale clients. ''; example = ["mydomain.internal"]; }; magic_dns = mkOption { type = types.bool; default = true; description = lib.mdDoc '' Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). Only works if there is at least a nameserver defined. ''; example = false; }; base_domain = mkOption { type = types.str; default = ""; description = lib.mdDoc '' Defines the base domain to create the hostnames for MagicDNS. {option}`baseDomain` must be a FQDNs, without the trailing dot. The FQDN of the hosts will be `hostname.namespace.base_domain` (e.g. `myhost.mynamespace.example.com`). ''; }; }; oidc = { issuer = mkOption { type = types.str; default = ""; description = lib.mdDoc '' URL to OpenID issuer. ''; example = "https://openid.example.com"; }; client_id = mkOption { type = types.str; default = ""; description = lib.mdDoc '' OpenID Connect client ID. ''; }; client_secret_path = mkOption { type = types.nullOr types.path; default = null; description = lib.mdDoc '' Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}. ''; }; scope = mkOption { type = types.listOf types.str; default = ["openid" "profile" "email"]; description = lib.mdDoc '' Scopes used in the OIDC flow. ''; }; extra_params = mkOption { type = types.attrsOf types.str; default = { }; description = lib.mdDoc '' Custom query parameters to send with the Authorize Endpoint request. ''; example = { domain_hint = "example.com"; }; }; allowed_domains = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' Allowed principal domains. if an authenticated user's domain is not in this list authentication request will be rejected. ''; example = [ "example.com" ]; }; allowed_users = mkOption { type = types.listOf types.str; default = [ ]; description = lib.mdDoc '' Users allowed to authenticate even if not in allowedDomains. ''; example = [ "alice@example.com" ]; }; strip_email_domain = mkOption { type = types.bool; default = true; description = lib.mdDoc '' Whether the domain part of the email address should be removed when generating namespaces. ''; }; }; tls_letsencrypt_hostname = mkOption { type = types.nullOr types.str; default = ""; description = lib.mdDoc '' Domain name to request a TLS certificate for. ''; }; tls_letsencrypt_challenge_type = mkOption { type = types.enum ["TLS-ALPN-01" "HTTP-01"]; default = "HTTP-01"; description = lib.mdDoc '' Type of ACME challenge to use, currently supported types: `HTTP-01` or `TLS-ALPN-01`. ''; }; tls_letsencrypt_listen = mkOption { type = types.nullOr types.str; default = ":http"; description = lib.mdDoc '' When HTTP-01 challenge is chosen, letsencrypt must set up a verification endpoint, and it will be listening on: `:http = port 80`. ''; }; tls_cert_path = mkOption { type = types.nullOr types.path; default = null; description = lib.mdDoc '' Path to already created certificate. ''; }; tls_key_path = mkOption { type = types.nullOr types.path; default = null; description = lib.mdDoc '' Path to key for already created certificate. ''; }; acl_policy_path = mkOption { type = types.nullOr types.path; default = null; description = lib.mdDoc '' Path to a file containg ACL policies. ''; }; }; }; }; }; }; 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"]) (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}"; # 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"; }; # 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. environment.etc."headscale/config.yaml".source = configFile; users.groups.headscale = mkIf (cfg.group == "headscale") {}; users.users.headscale = mkIf (cfg.user == "headscale") { description = "headscale user"; home = dataDir; group = cfg.group; isSystemUser = true; }; systemd.services.headscale = { description = "headscale coordination server for Tailscale"; after = ["network-online.target"]; wantedBy = ["multi-user.target"]; restartTriggers = [configFile]; environment.GIN_MODE = "release"; script = '' ${optionalString (cfg.settings.db_password_file != null) '' export HEADSCALE_DB_PASS="$(head -n1 ${escapeShellArg cfg.settings.db_password_file})" ''} exec ${cfg.package}/bin/headscale serve ''; 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"; 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"; 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]; }