Skip to content

VPN (Headscale / Tailscale)

The VPN meshes the zones, the coordination server and remote devices : Headscale 🡕 coordinates from the HCS, Tailscale 🡕 is the client on each node. This page gathers the operational workflows. Routing, DNS, MagicDNS, access policy and keys are explained in Tailnet operation.

Enabling coordination and declaring the HCS (profile: hcs) is enough :

etc/config.yaml
network:
coordination:
enable: true
hostname: "hcs"
domain: "headscale"

Each node’s role is derived from the topology :

NodeIdentityRole
HCStag:hcscoordination, DNS pivot, global services, exit node (closed)
Gatewaytag:gw-<zone>subnet router: publishes its zone’s /16
Admin workstationtag:adminSSH to all machines
Personal deviceits ownerservices, per its groups

An admin workstation is a host whose groups contains admin and whose host file enables the client :

usr/machines/portable/configuration.nix
darkone.service.tailscale.enable = true;
darkone.service.tailscale.autoPauseOnLan.enable = true;

autoPauseOnLan pauses the client when the laptop is plugged into a zone (NetworkManager required).

Machines join the tailnet without a shared key : the HCS creates a single-use key for each of them, with their declared tags. just configure handles this, and so does just full-install. The recipe alone can be re-run at any time :

Fenêtre de terminal
just tailnet-enroll gw
[ DNF ] TAILNET • gw not enrolled, backend NeedsLogin: sending a single-use key...
[ DNF ] TAILNET • gw: gw tag:gw-maison 100.64.0.8

It is idempotent :

SituationEffect
Node enrolled, name and tags conformnothing (already enrolled)
Different tags or namecorrected on the HCS, without reconnection
Never enrolled, deleted or reinstallednew key, registration
Older offline node with the same namedeleted, the new one takes the name
Older online node with the same namerefused : two machines carry this name
Host without Tailscale clientignored

The mechanism is detailed in Activation keys and enrollment.

A gateway enrolls like any machine ; two points are specific to it.

  1. Check its zone’s route

    The gateway advertises its zone’s /16, which the policy approves by default for tag:gw-<zone>. On the HCS :

    Fenêtre de terminal
    sudo headscale nodes list-routes
  2. Declare its tailnet address

    The HCS reaches the zone’s resolver through this address (DNS pivot), like the services that target it (backups, monitoring). Read it in the output of just tailnet-enroll, then report it :

    etc/config.yaml
    zones:
    maison:
    gateway:
    vpn:
    ipv4: "100.64.0.8"
  3. Propagate the address

    Fenêtre de terminal
    just generate
    just apply hcs

    Also redeploy the hosts that target this address. HeadscaleNodeDrift reports a declared address that no longer matches.

A smartphone or a personal computer connects with the Kanidm identity of its owner : neither key, nor manual validation.

Can connectCondition in etc/config.yaml
Administratoradmin or nix-admin profile
Member of a zonezone-<zone> group

These accounts form the Kanidm group tailnet, provisioned on its own ; Kanidm refuses the others. To open access, add the group to the user, regenerate, then just apply hcs. The user then follows Connect a device.

The device belongs to the person (alice@) and expires after 180 days : they then reconnect with their identifier.

Fenêtre de terminal
sudo headscale nodes list # devices, owners, expiration
sudo headscale nodes expire -i <id> # force a reconnection
sudo headscale nodes delete -i <id> # remove the device

The policy is generated from the topology : its rules are detailed in Access policy. Options adjust it, on the HCS :

Option darkone.service.headscale.DefaultEffect
policy.adminDevices{ }personal devices allowed in SSH : name → tailnet IPv4
policy.exitNodeSources[ ]sources allowed to reach the Internet via the HCS
policy.extraHosts{ }additional aliases : name → CIDR
policy.extraAcls[ ]rules appended after the generated rules
policy.enforcetruefalse : everything allowed, migration only
nodeExpiry"180d"expiration of personal devices (0 : never)
usr/machines/hcs/configuration.nix
darkone.service.headscale.policy = {
# SSH depuis le téléphone d'Alice (adresse lue dans `headscale nodes list`)
adminDevices.phone-alice = "100.64.0.9";
# Exit node du HCS ouvert aux administrateurs
exitNodeSources = [ "group:admins" ];
# Imprimante réseau pour tous les appareils personnels
extraHosts.printer = "10.0.4.10/32";
extraAcls = [
{
action = "accept";
src = [ "autogroup:member" ];
dst = [ "printer:631" ];
}
];
};

Then just apply hcs : the policy is checked at build time, and Headscale reloads it without restarting.

On the HCS (just enter hcs) :

CommandUsage
sudo headscale nodes listnodes, owner, tags, addresses, expiration
sudo headscale nodes list-routesadvertised and approved routes
sudo headscale nodes rename -i <id> <name>rename a node
sudo headscale nodes expire -i <id>force a reconnection
sudo headscale nodes delete -i <id>delete a node
sudo headscale users listowners of personal devices
sudo headscale preauthkeys listpre-authorized keys : tags, usage, expiration
sudo headscale policy getserved policy
sudo dnf-tailnet-enroll status <host>declared identity of a host

Tags and routes are not set by hand : just tailnet-enroll sets the declared tags, the policy approves the routes. The other client settings (advertised routes, Tailscale SSH off, exit node) are realigned by tailscaled-autoconnect, at startup and at deployments that modify it.

CommandAnswers
tailscale statuspeers, online or not, direct path or relay
tailscale ping <node>actual path : via <ip>:41641 (direct) or via DERP
tailscale whois <ip>node, tags or owner behind an address
tailscale dns statusMagicDNS, split DNS, search domains received
journalctl -u tailscaled-autoconnectlast registration or realignment of the settings

Without a --bypass… option, headscale policy check compiles a file against the real nodes and runs its tests block, without touching the policy in place.

  1. Start from the served policy :

    Fenêtre de terminal
    sudo headscale policy get > /tmp/policy.json
  2. Add a tests block : a source, then the expected destinations, each a single host (address and port) :

    "tests": [
    { "src": "alice@", "accept": ["100.64.0.8:443"], "deny": ["100.64.0.8:22"] }
    ]
  3. Check :

    Fenêtre de terminal
    sudo headscale policy check -f /tmp/policy.json
SymptomCheckRemedy
TailscaleUnhealthy, backend NeedsLogintailscale status on the nodejust tailnet-enroll <host>
Zone unreachable from the tailnetheadscale nodes list-routesgateway enrolled with its tag, route approved
Zone service refused to a deviceuser’s groups, tailscale whois <ip>add zone-<zone>, just apply hcs
SSH refused on a tailnet addresstag:admin machine or adminDevices devicedeclare the device
Internal names unresolved outside the zonetailscale dns statusenable “Use Tailscale DNS”
Slow traffictailscale ping shows via DERPoutbound UDP filtered, or strict NAT on both sides
no declared tailnet identityhost tagged, HCS deployed since its declarationjust apply hcs
named … is online, not deleting ittwo machines carry the same nameshut down the older one or rename the host

Every 15 minutes, headscale-audit compares the real nodes against the declared topology, without modifying anything. The details of the discrepancies are in its journal :

Fenêtre de terminal
journalctl -u headscale-audit -n 20
AlertReportsAfter
HeadscaleNodeDriftHCS or gateway missing, wrong tags, address different from the declared one30 min
HeadscaleUnexpectedNodetagged node not declared, or device from an account outside the tailnet groupimmediately
HeadscalePolicyInvalidpolicy refused, or served policy different from the file30 min
HeadscaleOidcFallbackKanidm missing at Headscale startup : OIDC connections cutimmediately
HeadscaleNodeExpiringpersonal device expiring within 7 days1 h
HeadscaleAuditStaleaudit without success for over an hourimmediately

All are in warning, on the Prometheus that supervises the HCS. A missing admin machine is not an anomaly ; if present, its tags are checked.

A tailscaled can silently detach from Headscale : the control connection is lost while the service remains “active”, and a gateway’s subnet becomes unreachable. A local watchdog detects and fixes this case, on each Tailscale client.

AspectBehavior
Probeevery 60 s : tailscale status (backend, online status)
Debounce3 consecutive failed probes (~3 min) before acting
Fixrestarts tailscaled, which reconnects to the control plane
Anti-loopa single fix per 10 min window
Unenrolled nodeno restart (NeedsLogin) : just tailnet-enroll
Portable on pausewatchdog stopped, metric removed
Observabilitydnf_tailscale_healthy, dnf_tailscale_selfheal_restarts_total (textfile node_exporter)

Two alerts accompany the mechanism :

  • TailscaleUnhealthy : still detached after 10 min, despite the fix ;
  • TailscaleFlapping : more than 3 fixes in one hour.

On a gateway, dnsmasq forwards any domain.tld to the HCS resolver through the tailnet : the client would need the VPN to find the server that brings up the VPN. At boot, moreover, the local resolver is not listening yet.

Each Tailscale client therefore receives the HCS public address in its /etc/hosts, derived from etc/config.yaml (the HCS itself is excluded). dnsmasq answers this name from that file, never forwarding it :

Fenêtre de terminal
grep headscale /etc/hosts # 203.0.113.10 headscale.domain.tld

The client thus connects without any resolver, including when the local DNS is down, precisely when the self-repair must be able to act.

  • Personal device : see Connect a personal device.
  • Admin laptop : on the move, it joins all zones through the tailnet (just enter <hôte>). Plugged back into a zone, its client pauses and the local gateway takes over again.