diff --git a/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml index c945112ca7a0..d28cd323a7d9 100644 --- a/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml +++ b/nixos/doc/manual/from_md/release-notes/rl-2205.section.xml @@ -185,6 +185,15 @@ services.rstudio-server. + + + headscale, + an Open Source implementation of the + Tailscale + Control Server. Available as + services.headscale + +
diff --git a/nixos/doc/manual/release-notes/rl-2205.section.md b/nixos/doc/manual/release-notes/rl-2205.section.md index e9ae1fa7ee7f..c09ee06b8606 100644 --- a/nixos/doc/manual/release-notes/rl-2205.section.md +++ b/nixos/doc/manual/release-notes/rl-2205.section.md @@ -55,6 +55,8 @@ In addition to numerous new and upgraded packages, this release has the followin - [rstudio-server](https://www.rstudio.com/products/rstudio/#rstudio-server), a browser-based version of the RStudio IDE for the R programming language. Available as [services.rstudio-server](options.html#opt-services.rstudio-server.enable). +- [headscale](https://github.com/juanfont/headscale), an Open Source implementation of the [Tailscale](https://tailscale.io) Control Server. Available as [services.headscale](options.html#opt-services.headscale.enable) + ## Backward Incompatibilities {#sec-release-22.05-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index c2b1e8866863..e7a320d0c143 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -764,6 +764,7 @@ ./services/networking/gvpe.nix ./services/networking/hans.nix ./services/networking/haproxy.nix + ./services/networking/headscale.nix ./services/networking/hostapd.nix ./services/networking/htpdate.nix ./services/networking/hylafax/default.nix diff --git a/nixos/modules/services/networking/headscale.nix b/nixos/modules/services/networking/headscale.nix new file mode 100644 index 000000000000..091d2a938cd4 --- /dev/null +++ b/nixos/modules/services/networking/headscale.nix @@ -0,0 +1,490 @@ +{ 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 "headscale, Open Source coordination server for Tailscale"; + + package = mkOption { + type = types.package; + default = pkgs.headscale; + defaultText = literalExpression "pkgs.headscale"; + description = '' + Which headscale package to use for the running server. + ''; + }; + + user = mkOption { + default = "headscale"; + type = types.str; + description = '' + User account under which headscale runs. + + 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 = '' + Group under which headscale runs. + + 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. + + ''; + }; + + serverUrl = mkOption { + type = types.str; + default = "http://127.0.0.1:8080"; + description = '' + The url clients will connect to. + ''; + example = "https://myheadscale.example.com:443"; + }; + + address = mkOption { + type = types.str; + default = "127.0.0.1"; + description = '' + Listening address of headscale. + ''; + example = "0.0.0.0"; + }; + + port = mkOption { + type = types.port; + default = 8080; + description = '' + Listening port of headscale. + ''; + example = 443; + }; + + privateKeyFile = mkOption { + type = types.path; + default = "${dataDir}/private.key"; + description = '' + Path to 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 = '' + List of urls containing DERP maps. + See How Tailscale works for more information on DERP maps. + ''; + }; + + paths = mkOption { + type = types.listOf types.path; + default = [ ]; + description = '' + List of file paths containing DERP maps. + See How Tailscale works for more information on DERP maps. + ''; + }; + + + autoUpdate = mkOption { + type = types.bool; + default = true; + description = '' + Whether to automatically update DERP maps on a set frequency. + ''; + example = false; + }; + + updateFrequency = mkOption { + type = types.str; + default = "24h"; + description = '' + Frequency to update DERP maps. + ''; + example = "5m"; + }; + + }; + + ephemeralNodeInactivityTimeout = mkOption { + type = types.str; + default = "30m"; + description = '' + Time before an inactive ephemeral node is deleted. + ''; + example = "5m"; + }; + + database = { + type = mkOption { + type = types.enum [ "sqlite3" "postgres" ]; + example = "postgres"; + default = "sqlite3"; + description = "Database engine to use."; + }; + + 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."; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/run/keys/headscale-dbpassword"; + description = '' + A file containing the password corresponding to + . + ''; + }; + + path = mkOption { + type = types.nullOr types.str; + default = "${dataDir}/db.sqlite"; + description = "Path to the sqlite3 database file."; + }; + }; + + logLevel = mkOption { + type = types.str; + default = "info"; + description = '' + headscale log level. + ''; + example = "debug"; + }; + + dns = { + nameservers = mkOption { + type = types.listOf types.str; + default = [ "1.1.1.1" ]; + description = '' + List of nameservers to pass to Tailscale clients. + ''; + }; + + domains = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Search domains to inject to Tailscale clients. + ''; + example = [ "mydomain.internal" ]; + }; + + magicDns = mkOption { + type = types.bool; + default = true; + description = '' + Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). + Only works if there is at least a nameserver defined. + ''; + example = false; + }; + + baseDomain = mkOption { + type = types.str; + default = ""; + description = '' + Defines the base domain to create the hostnames for MagicDNS. + 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). + ''; + }; + }; + + openIdConnect = { + issuer = mkOption { + type = types.str; + default = ""; + description = '' + URL to OpenID issuer. + ''; + example = "https://openid.example.com"; + }; + + clientId = mkOption { + type = types.str; + default = ""; + description = '' + OpenID Connect client ID. + ''; + }; + + clientSecretFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to OpenID Connect client secret file. + ''; + }; + + domainMap = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Domain map is used to map incomming users (by their email) to + a namespace. The key can be a string, or regex. + ''; + example = { + ".*" = "default-namespace"; + }; + }; + + }; + + tls = { + letsencrypt = { + hostname = mkOption { + type = types.nullOr types.str; + default = ""; + description = '' + Domain name to request a TLS certificate for. + ''; + }; + challengeType = mkOption { + type = types.enum [ "TLS_ALPN-01" "HTTP-01" ]; + default = "HTTP-01"; + description = '' + Type of ACME challenge to use, currently supported types: + HTTP-01 or TLS_ALPN-01. + ''; + }; + httpListen = mkOption { + type = types.nullOr types.str; + default = ":http"; + description = '' + When HTTP-01 challenge is chosen, letsencrypt must set up a + verification endpoint, and it will be listening on: + :http = port 80. + ''; + }; + }; + + certFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to already created certificate. + ''; + }; + keyFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to key for already created certificate. + ''; + }; + }; + + aclPolicyFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containg ACL policies. + ''; + }; + + settings = mkOption { + type = settingsFormat.type; + default = { }; + description = '' + Overrides to config.yaml as a Nix attribute set. + This option is ideal for overriding settings not exposed as Nix options. + Check the example config + for possible options. + ''; + }; + + + }; + + }; + config = mkIf cfg.enable { + + services.headscale.settings = { + server_url = mkDefault cfg.serverUrl; + listen_addr = mkDefault "${cfg.address}:${toString cfg.port}"; + + private_key_path = mkDefault cfg.privateKeyFile; + + derp = { + urls = mkDefault cfg.derp.urls; + paths = mkDefault cfg.derp.paths; + auto_update_enable = mkDefault cfg.derp.autoUpdate; + update_frequency = mkDefault cfg.derp.updateFrequency; + }; + + # Turn off update checks since the origin of our package + # is nixpkgs and not Github. + disable_check_updates = true; + + ephemeral_node_inactivity_timeout = mkDefault cfg.ephemeralNodeInactivityTimeout; + + db_type = mkDefault cfg.database.type; + db_path = mkDefault cfg.database.path; + + log_level = mkDefault cfg.logLevel; + + dns_config = { + nameservers = mkDefault cfg.dns.nameservers; + domains = mkDefault cfg.dns.domains; + magic_dns = mkDefault cfg.dns.magicDns; + base_domain = mkDefault cfg.dns.baseDomain; + }; + + unix_socket = "${runDir}/headscale.sock"; + + # OpenID Connect + oidc = { + issuer = mkDefault cfg.openIdConnect.issuer; + client_id = mkDefault cfg.openIdConnect.clientId; + domain_map = mkDefault cfg.openIdConnect.domainMap; + }; + + tls_letsencrypt_cache_dir = "${dataDir}/.cache"; + + } // optionalAttrs (cfg.database.host != null) { + db_host = mkDefault cfg.database.host; + } // optionalAttrs (cfg.database.port != null) { + db_port = mkDefault cfg.database.port; + } // optionalAttrs (cfg.database.name != null) { + db_name = mkDefault cfg.database.name; + } // optionalAttrs (cfg.database.user != null) { + db_user = mkDefault cfg.database.user; + } // optionalAttrs (cfg.tls.letsencrypt.hostname != null) { + tls_letsencrypt_hostname = mkDefault cfg.tls.letsencrypt.hostname; + } // optionalAttrs (cfg.tls.letsencrypt.challengeType != null) { + tls_letsencrypt_challenge_type = mkDefault cfg.tls.letsencrypt.challengeType; + } // optionalAttrs (cfg.tls.letsencrypt.httpListen != null) { + tls_letsencrypt_listen = mkDefault cfg.tls.letsencrypt.httpListen; + } // optionalAttrs (cfg.tls.certFile != null) { + tls_cert_path = mkDefault cfg.tls.certFile; + } // optionalAttrs (cfg.tls.keyFile != null) { + tls_key_path = mkDefault cfg.tls.keyFile; + } // optionalAttrs (cfg.aclPolicyFile != null) { + acl_policy_path = mkDefault cfg.aclPolicyFile; + }; + + # 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 ]; + + script = '' + ${optionalString (cfg.database.passwordFile != null) '' + export HEADSCALE_DB_PASS="$(head -n1 ${escapeShellArg cfg.database.passwordFile})" + ''} + + export HEADSCALE_OIDC_CLIENT_SECRET="$(head -n1 ${escapeShellArg cfg.openIdConnect.clientSecretFile})" + 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" "~@priviledged" "@chown" ]; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX"; + }; + }; + }; + + meta.maintainers = with maintainers; [ kradalby ]; +}