diff options
Diffstat (limited to 'nixpkgs/nixos/modules/services/networking/headscale.nix')
-rw-r--r-- | nixpkgs/nixos/modules/services/networking/headscale.nix | 531 |
1 files changed, 531 insertions, 0 deletions
diff --git a/nixpkgs/nixos/modules/services/networking/headscale.nix b/nixpkgs/nixos/modules/services/networking/headscale.nix new file mode 100644 index 000000000000..03e6f86af53f --- /dev/null +++ b/nixpkgs/nixos/modules/services/networking/headscale.nix @@ -0,0 +1,531 @@ +{ + 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.str; + 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 containing 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]; +} |