nix: add NixOS module and tests (#2857)

This commit is contained in:
Kristoffer Dalby
2025-11-12 07:11:38 -06:00
committed by GitHub
parent 000d5c3b0c
commit d14be8d43b
8 changed files with 1085 additions and 28 deletions

41
nix/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Headscale NixOS Module
This directory contains the NixOS module for Headscale.
## Rationale
The module is maintained in this repository to keep the code and module
synchronized at the same commit. This allows faster iteration and ensures the
module stays compatible with the latest Headscale changes. All changes should
aim to be upstreamed to nixpkgs.
## Files
- **[`module.nix`](./module.nix)** - The NixOS module implementation
- **[`example-configuration.nix`](./example-configuration.nix)** - Example
configuration demonstrating all major features
- **[`tests/`](./tests/)** - NixOS integration tests
## Usage
Add to your flake inputs:
```nix
inputs.headscale.url = "github:juanfont/headscale";
```
Then import the module:
```nix
imports = [ inputs.headscale.nixosModules.default ];
```
See [`example-configuration.nix`](./example-configuration.nix) for configuration
options.
## Upstream
- [nixpkgs module](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/networking/headscale.nix)
- [nixpkgs package](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/he/headscale/package.nix)
The module in this repository may be newer than the nixpkgs version.

View File

@@ -0,0 +1,145 @@
# Example NixOS configuration using the headscale module
#
# This file demonstrates how to use the headscale NixOS module from this flake.
# To use in your own configuration, add this to your flake.nix inputs:
#
# inputs.headscale.url = "github:juanfont/headscale";
#
# Then import the module:
#
# imports = [ inputs.headscale.nixosModules.default ];
#
{ config, pkgs, ... }:
{
# Import the headscale module
# In a real configuration, this would come from the flake input
# imports = [ inputs.headscale.nixosModules.default ];
services.headscale = {
enable = true;
# Optional: Use a specific package (defaults to pkgs.headscale)
# package = pkgs.headscale;
# Listen on all interfaces (default is 127.0.0.1)
address = "0.0.0.0";
port = 8080;
settings = {
# The URL clients will connect to
server_url = "https://headscale.example.com";
# IP prefixes for the tailnet
# These use the freeform settings - you can set any headscale config option
prefixes = {
v4 = "100.64.0.0/10";
v6 = "fd7a:115c:a1e0::/48";
allocation = "sequential";
};
# DNS configuration with MagicDNS
dns = {
magic_dns = true;
base_domain = "tailnet.example.com";
# Whether to override client's local DNS settings (default: true)
# When true, nameservers.global must be set
override_local_dns = true;
nameservers = {
global = [ "1.1.1.1" "8.8.8.8" ];
};
};
# DERP (relay) configuration
derp = {
# Use default Tailscale DERP servers
urls = [ "https://controlplane.tailscale.com/derpmap/default" ];
auto_update_enabled = true;
update_frequency = "24h";
# Optional: Run your own DERP server
# server = {
# enabled = true;
# region_id = 999;
# stun_listen_addr = "0.0.0.0:3478";
# };
};
# Database configuration (SQLite is recommended)
database = {
type = "sqlite";
sqlite = {
path = "/var/lib/headscale/db.sqlite";
write_ahead_log = true;
};
# PostgreSQL example (not recommended for new deployments)
# type = "postgres";
# postgres = {
# host = "localhost";
# port = 5432;
# name = "headscale";
# user = "headscale";
# password_file = "/run/secrets/headscale-db-password";
# };
};
# Logging configuration
log = {
level = "info";
format = "text";
};
# Optional: OIDC authentication
# oidc = {
# issuer = "https://accounts.google.com";
# client_id = "your-client-id";
# client_secret_path = "/run/secrets/oidc-client-secret";
# scope = [ "openid" "profile" "email" ];
# allowed_domains = [ "example.com" ];
# };
# Optional: Let's Encrypt TLS certificates
# tls_letsencrypt_hostname = "headscale.example.com";
# tls_letsencrypt_challenge_type = "HTTP-01";
# Optional: Provide your own TLS certificates
# tls_cert_path = "/path/to/cert.pem";
# tls_key_path = "/path/to/key.pem";
# ACL policy configuration
policy = {
mode = "file";
path = "/var/lib/headscale/policy.hujson";
};
# You can add ANY headscale configuration option here thanks to freeform settings
# For example, experimental features or settings not explicitly defined above:
# experimental_feature = true;
# custom_setting = "value";
};
};
# Optional: Open firewall ports
networking.firewall = {
allowedTCPPorts = [ 8080 ];
# If running a DERP server:
# allowedUDPPorts = [ 3478 ];
};
# Optional: Use with nginx reverse proxy for TLS termination
# services.nginx = {
# enable = true;
# virtualHosts."headscale.example.com" = {
# enableACME = true;
# forceSSL = true;
# locations."/" = {
# proxyPass = "http://127.0.0.1:8080";
# proxyWebsockets = true;
# };
# };
# };
}

727
nix/module.nix Normal file
View File

@@ -0,0 +1,727 @@
{ config
, lib
, pkgs
, ...
}:
let
cfg = config.services.headscale;
dataDir = "/var/lib/headscale";
runDir = "/run/headscale";
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;
cliConfigFile = settingsFormat.generate "headscale.yaml" cliConfig;
assertRemovedOption = option: message: {
assertion = !lib.hasAttrByPath option cfg;
message =
"The option `services.headscale.${lib.options.showOption option}` was removed. " + message;
};
in
{
# Disable the upstream NixOS module to prevent conflicts
disabledModules = [ "services/networking/headscale.nix" ];
options = {
services.headscale = {
enable = lib.mkEnableOption "headscale, Open Source coordination server for Tailscale";
package = lib.mkPackageOption pkgs "headscale" { };
user = lib.mkOption {
default = "headscale";
type = lib.types.str;
description = ''
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 = lib.mkOption {
default = "headscale";
type = lib.types.str;
description = ''
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 = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Listening address of headscale.
'';
example = "0.0.0.0";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = ''
Listening port of headscale.
'';
example = 443;
};
settings = lib.mkOption {
description = ''
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 = lib.types.submodule {
freeformType = settingsFormat.type;
options = {
server_url = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:8080";
description = ''
The url clients will connect to.
'';
example = "https://myheadscale.example.com:443";
};
noise.private_key_path = lib.mkOption {
type = lib.types.path;
default = "${dataDir}/noise_private.key";
description = ''
Path to noise private key file, generated automatically if it does not exist.
'';
};
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 = lib.mkOption {
type = lib.types.str;
default = "100.64.0.0/10";
description = prefDesc;
};
v6 = lib.mkOption {
type = lib.types.str;
default = "fd7a:115c:a1e0::/48";
description = prefDesc;
};
allocation = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.types.listOf lib.types.str;
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.
'';
};
paths = lib.mkOption {
type = lib.types.listOf lib.types.path;
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.
'';
};
auto_update_enabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to automatically update DERP maps on a set frequency.
'';
example = false;
};
update_frequency = lib.mkOption {
type = lib.types.str;
default = "24h";
description = ''
Frequency to update DERP maps.
'';
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 = lib.mkOption {
type = lib.types.str;
default = "30m";
description = ''
Time before an inactive ephemeral node is deleted.
'';
example = "5m";
};
database = {
type = lib.mkOption {
type = lib.types.enum [
"sqlite"
"sqlite3"
"postgres"
];
example = "postgres";
default = "sqlite";
description = ''
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.
'';
};
sqlite = {
path = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "${dataDir}/db.sqlite";
description = "Path to the sqlite3 database file.";
};
write_ahead_log = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Enable WAL mode for SQLite. This is recommended for production environments.
<https://www.sqlite.org/wal.html>
'';
example = true;
};
};
postgres = {
host = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "127.0.0.1";
description = "Database host address.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 3306;
description = "Database host port.";
};
name = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "headscale";
description = "Database name.";
};
user = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "headscale";
description = "Database user.";
};
password_file = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/keys/headscale-dbpassword";
description = ''
A file containing the password corresponding to
{option}`database.user`.
'';
};
};
};
log = {
level = lib.mkOption {
type = lib.types.str;
default = "info";
description = ''
headscale log level.
'';
example = "debug";
};
format = lib.mkOption {
type = lib.types.str;
default = "text";
description = ''
headscale log format.
'';
example = "json";
};
};
dns = {
magic_dns = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
'';
example = false;
};
base_domain = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
Defines the base domain to create the hostnames for MagicDNS.
This domain must be different from the {option}`server_url`
domain.
{option}`base_domain` must be a FQDN, without the trailing dot.
The FQDN of the hosts will be `hostname.base_domain` (e.g.
`myhost.tailnet.example.com`).
'';
example = "tailnet.example.com";
};
override_local_dns = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to use the local DNS settings of a node or override
the local DNS settings and force the use of Headscale's DNS
configuration.
'';
example = false;
};
nameservers = {
global = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
List of nameservers to pass to Tailscale clients.
Required when {option}`override_local_dns` is true.
'';
};
};
search_domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Search domains to inject to Tailscale clients.
'';
example = [ "mydomain.internal" ];
};
};
oidc = {
issuer = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
URL to OpenID issuer.
'';
example = "https://openid.example.com";
};
client_id = lib.mkOption {
type = lib.types.str;
default = "";
description = ''
OpenID Connect client ID.
'';
};
client_secret_path = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Path to OpenID Connect client secret file. Expands environment variables in format ''${VAR}.
'';
};
scope = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"openid"
"profile"
"email"
];
description = ''
Scopes used in the OIDC flow.
'';
};
extra_params = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = ''
Custom query parameters to send with the Authorize Endpoint request.
'';
example = {
domain_hint = "example.com";
};
};
allowed_domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Allowed principal domains. if an authenticated user's domain
is not in this list authentication request will be rejected.
'';
example = [ "example.com" ];
};
allowed_users = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Users allowed to authenticate even if not in allowedDomains.
'';
example = [ "alice@example.com" ];
};
pkce = {
enabled = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable or disable PKCE (Proof Key for Code Exchange) support.
PKCE adds an additional layer of security to the OAuth 2.0
authorization code flow by preventing authorization code
interception attacks
See https://datatracker.ietf.org/doc/html/rfc7636
'';
example = true;
};
method = lib.mkOption {
type = lib.types.str;
default = "S256";
description = ''
PKCE method to use:
- plain: Use plain code verifier
- S256: Use SHA256 hashed code verifier (default, recommended)
'';
};
};
};
tls_letsencrypt_hostname = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "";
description = ''
Domain name to request a TLS certificate for.
'';
};
tls_letsencrypt_challenge_type = lib.mkOption {
type = lib.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`.
'';
};
tls_letsencrypt_listen = lib.mkOption {
type = lib.types.nullOr lib.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`.
'';
};
tls_cert_path = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to already created certificate.
'';
};
tls_key_path = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to key for already created certificate.
'';
};
policy = {
mode = lib.mkOption {
type = lib.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 = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
If the mode is set to "file", the path to a
HuJSON file containing ACL policies.
'';
};
};
};
};
};
};
};
imports = with lib; [
(mkRenamedOptionModule
[ "services" "headscale" "derp" "autoUpdate" ]
[ "services" "headscale" "settings" "derp" "auto_update_enabled" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "derp" "auto_update_enable" ]
[ "services" "headscale" "settings" "derp" "auto_update_enabled" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "derp" "paths" ]
[ "services" "headscale" "settings" "derp" "paths" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "derp" "updateFrequency" ]
[ "services" "headscale" "settings" "derp" "update_frequency" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "derp" "urls" ]
[ "services" "headscale" "settings" "derp" "urls" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "ephemeralNodeInactivityTimeout" ]
[ "services" "headscale" "settings" "ephemeral_node_inactivity_timeout" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "logLevel" ]
[ "services" "headscale" "settings" "log" "level" ]
)
(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" "openIdConnect" "issuer" ]
[ "services" "headscale" "settings" "oidc" "issuer" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "serverUrl" ]
[ "services" "headscale" "settings" "server_url" ]
)
(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" "tls" "letsencrypt" "challengeType" ]
[ "services" "headscale" "settings" "tls_letsencrypt_challenge_type" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "tls" "letsencrypt" "hostname" ]
[ "services" "headscale" "settings" "tls_letsencrypt_hostname" ]
)
(mkRenamedOptionModule
[ "services" "headscale" "tls" "letsencrypt" "httpListen" ]
[ "services" "headscale" "settings" "tls_letsencrypt_listen" ]
)
(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 = lib.mkIf cfg.enable {
assertions = [
{
assertion = with cfg.settings; dns.magic_dns -> dns.base_domain != "";
message = "dns.base_domain must be set when using MagicDNS";
}
{
assertion = with cfg.settings; dns.override_local_dns -> (dns.nameservers.global != [ ]);
message = "dns.nameservers.global must be set when dns.override_local_dns is true";
}
(assertRemovedOption [ "settings" "acl_policy_path" ] "Use `policy.path` instead.")
(assertRemovedOption [ "settings" "db_host" ] "Use `database.postgres.host` instead.")
(assertRemovedOption [ "settings" "db_name" ] "Use `database.postgres.name` instead.")
(assertRemovedOption [
"settings"
"db_password_file"
] "Use `database.postgres.password_file` instead.")
(assertRemovedOption [ "settings" "db_path" ] "Use `database.sqlite.path` instead.")
(assertRemovedOption [ "settings" "db_port" ] "Use `database.postgres.port` instead.")
(assertRemovedOption [ "settings" "db_type" ] "Use `database.type` instead.")
(assertRemovedOption [ "settings" "db_user" ] "Use `database.postgres.user` instead.")
(assertRemovedOption [ "settings" "dns_config" ] "Use `dns` instead.")
(assertRemovedOption [ "settings" "dns_config" "domains" ] "Use `dns.search_domains` instead.")
(assertRemovedOption [
"settings"
"dns_config"
"nameservers"
] "Use `dns.nameservers.global` instead.")
(assertRemovedOption [
"settings"
"oidc"
"strip_email_domain"
] "The strip_email_domain option got removed upstream")
];
services.headscale.settings = lib.mkMerge [
cliConfig
{
listen_addr = lib.mkDefault "${cfg.address}:${toString cfg.port}";
tls_letsencrypt_cache_dir = "${dataDir}/.cache";
}
];
environment = {
# 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 = lib.mkIf (cfg.group == "headscale") { };
users.users.headscale = lib.mkIf (cfg.user == "headscale") {
description = "headscale user";
home = dataDir;
group = cfg.group;
isSystemUser = true;
};
systemd.services.headscale = {
description = "headscale coordination server for Tailscale";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
script = ''
${lib.optionalString (cfg.settings.database.postgres.password_file != null) ''
export HEADSCALE_DATABASE_POSTGRES_PASS="$(head -n1 ${lib.escapeShellArg cfg.settings.database.postgres.password_file})"
''}
exec ${lib.getExe cfg.package} serve --config ${configFile}
'';
serviceConfig =
let
capabilityBoundingSet = [ "CAP_CHOWN" ] ++ lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
in
{
Restart = "always";
RestartSec = "5s";
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 lib.maintainers; [
kradalby
misterio77
];
}

102
nix/tests/headscale.nix Normal file
View File

@@ -0,0 +1,102 @@
{ pkgs, lib, ... }:
let
tls-cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } ''
openssl req \
-x509 -newkey rsa:4096 -sha256 -days 365 \
-nodes -out cert.pem -keyout key.pem \
-subj '/CN=headscale' -addext "subjectAltName=DNS:headscale"
mkdir -p $out
cp key.pem cert.pem $out
'';
in
{
name = "headscale";
meta.maintainers = with lib.maintainers; [
kradalby
misterio77
];
nodes =
let
headscalePort = 8080;
stunPort = 3478;
peer = {
services.tailscale.enable = true;
security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ];
};
in
{
peer1 = peer;
peer2 = peer;
headscale = {
services = {
headscale = {
enable = true;
port = headscalePort;
settings = {
server_url = "https://headscale";
ip_prefixes = [ "100.64.0.0/10" ];
derp.server = {
enabled = true;
region_id = 999;
stun_listen_addr = "0.0.0.0:${toString stunPort}";
};
dns = {
base_domain = "tailnet";
extra_records = [
{
name = "foo.bar";
type = "A";
value = "100.64.0.2";
}
];
override_local_dns = false;
};
};
};
nginx = {
enable = true;
virtualHosts.headscale = {
addSSL = true;
sslCertificate = "${tls-cert}/cert.pem";
sslCertificateKey = "${tls-cert}/key.pem";
locations."/" = {
proxyPass = "http://127.0.0.1:${toString headscalePort}";
proxyWebsockets = true;
};
};
};
};
networking.firewall = {
allowedTCPPorts = [
80
443
];
allowedUDPPorts = [ stunPort ];
};
environment.systemPackages = [ pkgs.headscale ];
};
};
testScript = ''
start_all()
headscale.wait_for_unit("headscale")
headscale.wait_for_open_port(443)
# Create headscale user and preauth-key
headscale.succeed("headscale users create test")
authkey = headscale.succeed("headscale preauthkeys -u 1 create --reusable")
# Connect peers
up_cmd = f"tailscale up --login-server 'https://headscale' --auth-key {authkey}"
peer1.execute(up_cmd)
peer2.execute(up_cmd)
# Check that they are reachable from the tailnet
peer1.wait_until_succeeds("tailscale ping peer2")
peer2.wait_until_succeeds("tailscale ping peer1.tailnet")
assert (res := peer1.wait_until_succeeds("${lib.getExe pkgs.dig} +short foo.bar").strip()) == "100.64.0.2", f"Domain {res} did not match 100.64.0.2"
'';
}