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 ];
+}