Post

Headscale serveur de contrôle des clients Tailscale

Headscale serveur de contrôle des clients Tailscale

Headscale vise à mettre en œuvre une alternative open source auto-hébergée au serveur de contrôle Tailscale. L’objectif de Headscale est de fournir aux auto-hébergeurs et aux amateurs un serveur open source qu’ils peuvent utiliser pour leurs projets et leurs laboratoires. Il met en œuvre une portée étroite, un réseau unique Tailscale (tailnet), adapté à un usage personnel, ou une petite organisation open source.

Headscale + DERP intégré

Procédure complète pour installer Headscale + DERP intégré sur un VPS Debian, derrière Caddy, en utilisant le nom headscale.xoyize.xyz

VPS sxb

  • IP: 164.132.198.38
  • IPv6: 2001:41d0:404:200::7ec4
  • Domaine xoyize.xyz, zone dns configurée headscale.xoyize.xyz

On utilisera une seule URL publique : https://headscale.xoyize.xyz

Répartition des ports :

Service Écoute locale/public Rôle
Caddy TCP 80, TCP 443 HTTPS, certificat Let’s Encrypt, proxy Headscale et DERP
Headscale 127.0.0.1:8080 API / serveur de coordination
DERP intégré HTTPS via Caddy + UDP 3478 Relais DERP et STUN
SSH Ton port SSH non standard Administration VPS

Le trafic NFS ne doit jamais être ouvert sur ce VPS de coordination. Le VPS est seulement le point de contrôle/relay mesh, NFS restera accessible uniquement via l’interface mesh de ton serveur Debian.

Mise à jour debian
Se connecter au VPS puis mise à jour du système avec des utilitaitres :

1
2
3
sudo apt update
sudo apt full-upgrade -y
sudo apt install -y ca-certificates curl  gnupg ufw jq dnsutils

Vérifier le nom DNS depuis le VPS

1
2
dig +short A headscale.xoyize.xyz
dig +short AAAA headscale.xoyize.xyz

Résultat attendu :

1
2
164.132.198.38
2001:41d0:404:200::7ec4

Vérifier que le serveur possède bien ces IP :

1
2
3
ip -4 addr
ip -6 addr
ip -6 route

Résultat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
2: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    altname enp0s3
    altname enxfa163e8b59df
    inet 164.132.198.38/32 metric 100 scope global dynamic ens3
       valid_lft 81962sec preferred_lft 81962sec
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
    inet6 ::1/128 scope host noprefixroute 
       valid_lft forever preferred_lft forever
2: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
    inet6 2001:41d0:404:200::7ec4/128 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::f816:3eff:fe8b:59df/64 scope link proto kernel_ll 
       valid_lft forever preferred_lft forever
2001:41d0:404:200::7ec4 dev ens3 proto kernel metric 256 pref medium
2001:41d0:404:200::/64 dev ens3 proto static metric 1024 pref medium
fe80::/64 dev ens3 proto kernel metric 256 pref medium
default via 2001:41d0:404:200::1 dev ens3 proto static metric 1024 pref medium

Pare-feu UFW

Avant d’activer UFW, adapter le port SSH à la configuration réelle. Ne pas copier 22 si le VPS utilise un autre port.

Pour une sécurité de base, laisser le pare-feu en refus entrant par défaut

1
2
3
4
5
6
7
8
9
10
sudo ufw default deny incoming
sudo ufw default allow outgoing

sudo ufw allow 55038/tcp comment 'SSH administration'
sudo ufw allow 80/tcp comment 'Caddy ACME HTTP'
sudo ufw allow 443/tcp comment 'Headscale and DERP HTTPS'
sudo ufw allow 3478/udp comment 'Headscale DERP STUN'

sudo ufw enable
sudo ufw status verbose

Il faut impérativement d’avoir une session SSH fonctionnelle ouverte avant sudo ufw enable.

Il faut ouvrir les règles en IPv4 et IPv6 à vérifier sur /etc/default/ufw

1
grep 'IPV6' /etc/default/ufw  # renvoie si ok: IPV6=yes

Le DERP intégré Headscale requiert STUN ; la documentation Headscale liste STUN parmi les exigences lorsque le DERP intégré est activé.[headscale]

Installer Caddy

Le serveur Headscale doit être accessible en HTTPS avec un certificat TLS valide pour fonctionner.

Installer Caddy depuis le dépôt officiel :

1
2
3
4
5
6
7
8
9
10
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list

sudo apt update
sudo apt install -y caddy

Vérification

1
2
systemctl status caddy --no-pager
caddy version  # v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=  au 26/09/2026

Caddy gère HTTPS automatiquement lorsqu’un nom de domaine est déclaré dans le Caddyfile, et le conserve renouvelé.

Caddy doit s’exécuter sous l’utilisateur système caddy, avec /var/lib/caddy accessible en écriture par cet utilisateur. Le mécanisme de certificats automatiques dépend de ce répertoire.

1
2
3
4
5
sudo systemctl stop caddy
sudo install -d  -o caddy -g caddy -m 0750 /var/lib/caddy
sudo chown -R caddy:caddy /var/lib/caddy
sudo systemctl start caddy
sudo systemctl status caddy --no-pager -l

Vérifier ensuite droits et propriétaire

1
2
3
sudo ls -ld /var/lib/caddy
sudo ls -ld /var/lib/caddy/.config
sudo ls -ld /var/lib/caddy/.config/caddy

On doit voir caddy caddy comme propriétaire et groupe

1
2
3
drwxr-x--- 4 caddy caddy 4096 26 sept. 12:12 /var/lib/caddy
drwxr-xr-x 3 caddy caddy 4096 26 sept. 12:12 /var/lib/caddy/.config
drwxr-x--- 2 caddy caddy 4096 26 sept. 12:12 /var/lib/caddy/.config/caddy

Headscale

Installer headscale

Au moment de l’installation, utiliser la dernière version stable publiée dans les releases Headscale. La documentation propose des paquets .deb pour Debian/Ubuntu.headscale

Définir d’abord la version à installer en consultant les releases officielles, puis remplacer X.Y.Z

1
2
3
4
5
6
7
HEADSCALE_VERSION="0.29.4"

curl -fsSLO \
  "https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_amd64.deb"

sudo apt install -y \
  "./headscale_${HEADSCALE_VERSION}_linux_amd64.deb"

Vérifier les fichiers installés :

1
2
3
headscale version
systemctl status headscale --no-pager
sudo ls -la /etc/headscale

À ce stade, arrêter Headscale pour modifier proprement sa configuration :

1
sudo systemctl stop headscale

Configurer Headscale et DERP

Sauvegarder la configuration générée par le paquet :

1
2
sudo cp -a /etc/headscale/config.yaml \
  "/etc/headscale/config.yaml.bak.$(date +%F-%H%M%S)"

Editer /etc/headscale/config.yaml

1
sudo nano /etc/headscale/config.yaml

Vérifier ou remplacer les blocs pertinents. Les noms exacts de quelques paramètres peuvent varier selon la version, partir du fichier d’exemple correspondant exactement à la version installée, plutôt que d’écraser la configuration complète avec un exemple d’une autre release.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server_url: https://headscale.xoyize.xyz
listen_addr: 127.0.0.1:8080
metrics_listen_addr: 127.0.0.1:9090


prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential
derp:
  server:
    enabled: true
    region_id: 999
    region_code: "xoyize"
    region_name: "Xoyize DERP"
    verify_clients: true
    stun_listen_addr: "0.0.0.0:3478"
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true
    ipv4: 164.132.198.38
    ipv6: 2001:41d0:404:200::7ec4
  urls: []
  paths: []
  auto_update_enabled: false

Les éléments essentiels

  • server_url doit être exactement l’URL HTTPS publique.
  • listen_addr: 127.0.0.1:8080 évite d’exposer directement l’API Headscale.
  • derp.server.enabled: true active le DERP intégré.
  • ipv4 et ipv6 déclarent les IP publiques réellement annoncées aux clients.
  • stun_listen_addr rend STUN disponible sur UDP 3478.
  • Les listes urls et paths vides, avec auto_update_enabled: false, évitent de charger les relais publics par défaut : tes nœuds utiliseront ton DERP intégré. La documentation Headscale indique que le DERP intégré est désactivé par défaut, qu’il doit être activé explicitement, et montre la déclaration des adresses publiques IPv4/IPv6.[headscale]

Créer le répertoire de données et corriger les permissions si nécessaire :

1
2
sudo install -d -o headscale -g headscale -m 0750 /var/lib/headscale
sudo chown -R headscale:headscale /var/lib/headscale

Puis démarrer et vérifier :

1
2
3
sudo systemctl enable --now headscale
sudo systemctl status headscale --no-pager -l
sudo journalctl -u headscale -n 100 --no-pager -l

Résultat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
● headscale.service - headscale coordination server for Tailscale
     Loaded: loaded (/usr/lib/systemd/system/headscale.service; enabled; preset: enabled)
     Active: active (running) since Sat 2026-09-26 11:55:32 CEST; 80ms ago
 Invocation: eeb5eacfe7a049cbbdb2b17506ebc794
   Main PID: 5841 (headscale)
      Tasks: 4 (limit: 4561)
     Memory: 5.4M (peak: 5.4M)
        CPU: 51ms
     CGroup: /system.slice/headscale.service
             └─5841 /usr/bin/headscale serve


sept. 26 11:41:22 vps-56d1c3c5 systemd[1]: Stopped headscale.service - headscale coordination server for Tailscale.
sept. 26 11:55:32 vps-56d1c3c5 systemd[1]: Started headscale.service - headscale coordination server for Tailscale.

Si le service ne démarre pas, lancer directement une validation de configuration avec la commande supportée par la release ou consulter immédiatement :

1
sudo journalctl -xeu headscale --no-pager

Caddy headscale.xoyize.xyz

Créer le Caddyfile: /etc/caddy/Caddyfile

1
2
3
4
5
6
7
sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
headscale.xoyize.xyz {
    encode zstd gzip

    reverse_proxy 127.0.0.1:8080
}
EOF

Dans cette architecture, Caddy utilise TLS sur TCP 443 et transmet les requêtes HTTP vers Headscale en local. La directive reverse_proxy est la directive Caddy prévue à cet effet.

Valider la configuration

1
2
3
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
# si message WARN	Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix...
sudo caddy fmt --overwrite --config /etc/caddy/Caddyfile

recharger :

1
2
3
sudo systemctl reload caddy
sudo systemctl status caddy --no-pager -l
sudo journalctl -u caddy -n 100 --no-pager -l

Résultat

1
2
3
sept. 26 12:02:40 vps-56d1c3c5 systemd[1]: Reloaded caddy.service - Caddy.
...
sept. 26 12:02:40 vps-56d1c3c5 systemd[1]: Reloaded caddy.service - Caddy.

Tester le certificat et le proxy :

1
2
curl -4 -I https://headscale.xoyize.xyz
curl -6 -I https://headscale.xoyize.xyz

Un code HTTP 404, 401 ou une réponse d’API est acceptable : ce qui compte est d’obtenir une connexion HTTPS valide et une réponse de Headscale. Une erreur de certificat, un timeout ou une connexion refusée doit être corrigé avant d’enrôler des machines.

Vérifications DERP/STUN

Depuis le VPS :

1
sudo ss -lntup | grep -E ':(80|443|3478|8080)\b'

Attendu :

  • Caddy sur TCP 80 et 443.
  • Headscale uniquement sur 127.0.0.1:8080.
  • DERP/STUN sur UDP 3478, IPv4 et IPv6 si le système et la configuration sont corrects.

Vérifier écoute UDP IPv6 explicitement :

1
2
3
4
5
udp   UNCONN 0      0                        *:443              *:*    users:(("caddy",pid=5996,fd=7))          
udp   UNCONN 0      0                        *:3478             *:*    users:(("headscale",pid=5841,fd=11))     
tcp   LISTEN 0      128              127.0.0.1:8080       0.0.0.0:*    users:(("headscale",pid=5841,fd=13))     
tcp   LISTEN 0      4096                     *:80               *:*    users:(("caddy",pid=5996,fd=8))          
tcp   LISTEN 0      4096                     *:443              *:*    users:(("caddy",pid=5996,fd=6)) 

Clé API headscale

À quoi sert une clé API Headscale Elle sert à s’authentifier auprès de l’API distante de Headscale (REST/gRPC), pas des clients Tailscale. Elle n’est utile que si quelque chose de distant pilote Headscale :

  • une interface web (headscale-ui, Headplane, headscale-admin…) ;
  • le CLI headscale lancé depuis un autre poste (variables HEADSCALE_CLI_ADDRESS et HEADSCALE_CLI_API_KEY) ;
  • un script, un exporter de métriques ou un fournisseur Terraform.

Le CLI lancé sur le VPS lui-même passe par le socket Unix et n’a pas besoin de clé. Vos clients Tailscale n’en utilisent aucune, ils s’enregistrent avec des clés de pré-authentification ou une connexion OIDC.

Faut-il la renouveler ? Seulement si un outil l’utilise encore. Pour le savoir :

1
2
headscale apikeys list
headscale apikeys list -o json | jq '.[] | {prefix, expiration, last_seen}'

Si le champ last_seen existe dans votre version, une clé non utilisée depuis longtemps est probablement orpheline. Sinon, repérez l’outil concerné : une interface web installée, une variable HEADSCALE_CLI_API_KEY dans un fichier ou une unité systemd, ou des appels à /api/v1 dans les journaux de votre reverse proxy.

  • Rien ne l’utilise : supprimez-la, sans la renouveler.
    1
    2
    
    headscale apikeys expire --prefix PREFIXE
    headscale apikeys delete --prefix PREFIXE
    
  • Un outil l’utilise : créez la nouvelle clé avant d’expirer l’ancienne.
    1
    
    headscale apikeys create --expiration 90d    # la clé n'est affichée qu'une seule fois
    

    Remplacez-la dans l’outil, vérifiez qu’il fonctionne, puis faites expire et delete sur l’ancienne. Les options (--prefix, --id) varient selon la version, headscale apikeys --help donne les bonnes.

Pourquoi le warning peut persister : mon contrôle compte aussi les clés déjà expirées. Tant qu’une clé expirée n’est pas supprimée avec delete, l’alerte reste. Si vous préférez ne surveiller que les clés encore actives qui expirent bientôt, je peux ajuster le filtre dans headscale-watch.sh.

Sur la configuration, il y a une clé qui a expiré (headscale apikeys list -o json | jq '.[] | {prefix, expiration, last_seen}')

1
2
3
4
5
6
7
8
9
10
11
ID | Prefix                     | Expiration          | Created            
1  | hskey-api-lf_zuZ_klD_X-*** | 2026-09-26 20:14:30 | 2026-09-26 18:06:50

{
  "prefix": "hskey-api-lf_zuZ_klD_X-***",
  "expiration": {
    "seconds": 1790453670,
    "nanos": 20473611
  },
  "last_seen": null
}

On va l’effacer

1
2
# headscale apikeys delete --prefix PREFIXE
headscale apikeys delete --prefix hskey-api-lf_zuZ_klD_X-***

Politique serveur Headscale

Très bien. Maintenant que le contrôle Headscale, le DERP et le HTTPS fonctionnent, la bonne suite consiste à définir le modèle de confiance avant d’inscrire le reste du parc, puis à verrouiller NFS à trois niveaux : politique mesh, pare-feu local, et export NFS.

Séparer les rôles ainsi :

Rôle Tag Machines typiques Accès NFS
Administration tag:admin Desktop/laptop Arch de confiance SSH vers les serveurs ; éventuellement NFS si également client
Serveur NFS tag:nfs-server Le Debian qui héberge le dossier Reçoit TCP 2049 seulement depuis les clients NFS
Client NFS tag:nfs-client Desktop/laptop Arch et VM Debian qui montent le partage TCP 2049 uniquement vers le serveur NFS
Mobile tag:mobile Android Aucun accès NFS
Serveur standard tag:server VPS/VM Debian sans NFS SSH administré depuis tag:admin

Les ACL Headscale se configurent dans un fichier de politique HuJSON dont le chemin doit être défini via policy.path dans config.yaml. Les tags sont appliqués à l’enregistrement ou administrés avec headscale nodes tag.[headscale][headscale]

Inventaire et rôles

Alias Système / rôle Adresse actuelle Tag(s) Headscale Accès NFS
cwwk Serveur Debian NFS 192.168.0.205 tag:server, tag:nfs-server Héberge l’export
e6230w Laptop CachyOS 192.168.10.90 tag:admin, tag:nfs-client Monte l’export
pc1 Desktop EndeavourOS LAN, non listé dans CSV tag:admin, tag:nfs-client Monte l’export
proxmox Hôte Proxmox 192.168.0.215 tag:server Non
vm105 VM Debian 192.168.0.229 tag:server Non initialement
skrime VPS IPv6-only 2a14:7c0:1002:19a8:: tag:server Non
yannig VPS Debian 51.38.37.240 tag:server Non
xoyaz VPS Debian 51.254.133.45 tag:server Non
yannir VPS Debian 144.91.89.149 tag:server Non
yiak VPS Debian 83.228.219.134 tag:server Non
sxb VPS Headscale / DERP 164.132.198.38, 2001:41d0:404:200::7ec4 Aucun, ou tag:control-plane Non
Android Mobile Variable tag:mobile Jamais

skrime est le test prioritaire du VPS IPv6-only : ajoute-le assez tôt, après e6230w et cwwk, afin de confirmer le chemin vers Headscale et DERP sans dépendre d’IPv4.

Vérifier utilisateur headscale

Avant de déposer cette politique, vérifier le nom exact de l’utilisateur Headscale :

1
sudo headscale users list

Si la sortie est, par exemple :

1
2
ID | Name
1  | yann

garder :

1
"group:admin": ["yann"]

S’il est affiché autrement, utiliser ce nom exact. Ne pas mettre yann@ sans raison : Headscale attend l’identité configurée dans sa propre base, pas nécessairement une identité de fournisseur externe.

Vérifier ou déclarer le chemin /etc/headscale/policy.hujson dans Headscale

Sur le VPS Headscale, localiser la configuration et vérifier le chemin actuellement défini :

1
2
sudo grep -nE '^(policy:| *path:)' /etc/headscale/config.yaml
sudo grep -n -A5 -B2 '^policy:' /etc/headscale/config.yaml

On a un bloc de ce type :

1
2
3
4
5
6
7
8
9
10
11
220:    path: /var/lib/headscale/db.sqlite
295:policy:
300:  path: ""
293-# ACLs: https://tailscale.com/docs/features/access-control/acls
294-# Grants: https://tailscale.com/docs/features/access-control/grants
295:policy:
296-  # The mode can be "file" or "database" that defines
297-  # where the policies are stored and read from.
298-  mode: file
299-  # If the mode is set to "file", the path to a HuJSON file containing policies.
300-  path: ""

On ajoute

1
2
policy:
  path: "/etc/headscale/policy.hujson"

Le fichier /etc/headscale/policy.hujson

Créer /etc/headscale/policy.hujson sur sxb par cette politique. Elle est restrictive par défaut :

  • Les postes d’administration peuvent joindre chaque serveur uniquement sur son port SSH actuel.
  • Les clients NFS peuvent joindre cwwk seulement en TCP/2049.
  • Android n’obtient aucune règle vers NFS.
  • Aucun accès implicite « tous ports, toutes machines » n’existe.

Créer le fichier de politique avec des permissions restrictives :

1
2
3
4
sudo install -o root -g headscale -m 0640 \
  /dev/null /etc/headscale/policy.hujson

sudo nano /etc/headscale/policy.hujson

La politique suivante est plus sûre et ne suppose pas que les utilisateurs des machines sont identiques. Elle donne au seul compte d’administration yann@ le droit de gérer les tags, tout en appliquant les droits réseau par rôle de machine :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
{
  "groups": {
    "group:mesh-admin": ["yann@"]
  },

  "tagOwners": {
    "tag:admin": ["group:mesh-admin"],
    "tag:server": ["group:mesh-admin"],
    "tag:nfs-server": ["group:mesh-admin"],
    "tag:nfs-client": ["group:mesh-admin"],
    "tag:dns-server": ["group:mesh-admin"],
    "tag:mobile": ["group:mesh-admin"],
    "tag:control-plane": ["group:mesh-admin"]
  },

  "acls": [
    // Tes deux postes d'administration, une fois marqués tag:admin,
    // peuvent administrer les serveurs via leurs ports SSH existants.
    {
      "action": "accept",
      "src": ["tag:admin"],
      "dst": [
        "tag:server:51002",
        "tag:server:55045",
        "tag:server:55134",
        "tag:server:55149",
        "tag:server:55215",
        "tag:server:55229",
        "tag:server:55240",
        "tag:server:56230",
        "tag:server:55216",
        "tag:server:55224",
        "tag:nfs-server:55205"
      ]
    },

    // Partage NFS : exclusivement les nœuds tag:nfs-client,
    // exclusivement le nœud tag:nfs-server, exclusivement TCP/2049.
    {
      "action": "accept",
      "src": ["tag:nfs-client"],
      "proto": "tcp",
      "dst": ["tag:nfs-server:2049"]
    },
    {
      "action": "accept",
      "src": ["*"],
      "proto": "udp",
      "dst": ["tag:dns-server:53"]
    },
    {
      "action": "accept",
      "src": ["*"],
      "proto": "tcp",
      "dst": ["tag:dns-server:53"]
    },
    {
      "action": "accept",
      "src": ["*"],
      "proto": "tcp",
      "dst": ["tag:dns-server:443"]
    }
  ]
}

Dans cette version :

  • cwwk recevra : tag:server,tag:nfs-server.
  • e6230w recevra : tag:admin,tag:nfs-client.
  • pc1 recevra : tag:admin,tag:nfs-client.
  • proxmox, vm105, skrime, yannig, xoyaz, yannir, yiak reçoivent : tag:server.
  • Android reçoit : tag:mobile seulement.
  • sxb ne doit pas recevoir de tag:nfs-*; il peut rester non inscrit, ou être classé tag:control-plane si tu installes un client Tailscale pour l’administrer, mais ce n’est pas nécessaire.

Après enregistrement :

1
2
3
sudo systemctl restart headscale
sudo systemctl status headscale --no-pager -l
sudo journalctl -u headscale -n 100 --no-pager

Si Headscale refuse de démarrer, restaurer immédiatement le fichier précédent et lire l’erreur de syntaxe dans le journal.

Préserver le service pendant les tests

Ne redémarrer pas Headscale directement après une modification non validée. La version expose les outils de politique nécessaires :

1
2
3
4
5
6
7
8
9
# Sauvegarde
sudo headscale policy get > /tmp/policy-current.hujson

sudo cp -a /etc/headscale/policy.hujson \
  "/etc/headscale/policy.hujson.bak.$(date +%F-%H%M%S)"
# Modifier et vérifier
sudo headscale policy check \
  --file /etc/headscale/policy.hujson
# si OK --> Policy is valid

Redémarrer

1
sudo systemctl restart headscale

Cette politique Headscale applique le principe du moindre privilège : chaque règle décrit qui peut initier une connexion, vers quel type de machine et sur quel port. En l’absence de règle correspondante, le trafic n’est pas autorisé.
Le groupe group:mesh-admin rassemble les administrateurs autorisés à attribuer les tags. Ces tags servent à classer les appareils selon leur rôle — postes d’administration, serveurs, clients NFS ou serveur DNS — plutôt que de fonder les autorisations uniquement sur des adresses IP. Cela rend les règles plus lisibles et plus faciles à maintenir lorsque les appareils changent.
Les machines portant tag:admin peuvent accéder aux serveurs sur les ports SSH déclarés, ainsi qu’au port d’administration du serveur NFS. L’accès est donc limité aux postes identifiés comme administratifs et aux ports explicitement listés. Les commentaires mentionnent deux postes : en pratique, la règle s’applique à tout appareil portant tag:admin, et non à un nombre fixe de machines.
Pour le partage de fichiers, seuls les nœuds tag:nfs-client peuvent joindre le nœud tag:nfs-server, en TCP sur le port 2049. Les autres appareils du réseau privé ne reçoivent pas cet accès NFS par cette règle.
Le serveur Technitium fournit le DNS sur les ports 53 en UDP et en TCP. Les règles autorisent les nœuds du réseau à le joindre pour ces deux types de requêtes. Le port 443 est également ouvert en TCP vers ce même nœud : comme Nginx et Technitium se trouvent sur le même serveur, cet accès permet de joindre le service HTTPS publié à cette adresse. La politique Headscale contrôle ici l’accès au nœud et au port, pas le service précis derrière Nginx.
Enfin, les tags tag:mobile et tag:control-plane sont déclarés comme attribuables par les administrateurs, mais ils ne sont utilisés dans aucune règle ACL de cet extrait. Ils ne donnent donc pas, à eux seuls, de droit d’accès particulier.
À noter pour la précision technique :** les règles d’administration ne déclarent pas de protocole, contrairement aux règles NFS, DNS et HTTPS. Tu peux les décrire comme des accès aux ports listés, sans affirmer qu’ils sont limités à TCP tant que ce n’est pas indiqué dans la politique.

Clients Tailscale

Créer utilisateur et clé inscription sur serveur headscale

Headscale, vérifier d’abord les sous-commandes

1
2
headscale users --help
headscale preauthkeys --help

Créer les utilisateurs

1
2
3
4
5
6
7
8
9
10
sudo headscale users create yann
sudo headscale users create yick
sudo headscale users create yano
sudo headscale users create skim
sudo headscale users create leno
sudo headscale users create debjek
sudo headscale users create yannig
sudo headscale users create ugra
sudo headscale users create yai
sudo headscale users create yiak

Liste des utilisateurs

1
sudo headscale users list
1
2
3
4
5
6
7
8
9
10
11
ID | Name | Username | Email | Created            
1  |      | yann     |       | 2026-09-26 10:27:03
2  |      | yick     |       | 2026-09-26 12:01:00
3  |      | yano     |       | 2026-09-26 12:35:05
4  |      | skim     |       | 2026-09-26 13:06:49
5  |      | leno     |       | 2026-09-26 15:55:20
6  |      | debjek   |       | 2026-09-26 15:55:20
7  |      | yannig   |       | 2026-09-26 15:55:21
8  |      | ugra     |       | 2026-09-26 15:55:21
9  |      | yai      |       | 2026-09-26 15:55:22
10 |      | yiak     |       | 2026-09-26 15:55:22

Créer une clé d’inscription limitée dans le temps, par exemple 3 heures :

1
2
3
4
5
6
# syntaxe
sudo headscale preauthkeys create \
  --user ID \
  --expiration 3h
# ou
sudo headscale preauthkeys create -u 1 -e 3h  

Copier la clé générée dans un gestionnaire de mots de passe, elle permettra à un appareil de rejoindre le mesh.

Pour des serveurs, créer plutôt une clé distincte à usage unique et avec un tag prédéfini — par exemple tag:server — lorsque la politique ACL sera en place. Ne pas réutiliser une clé longue durée sur plusieurs machines.

Une fois la machine connectée, la clé d’inscription (`--authkey`) n’est normalement plus nécessaire pour que le client fonctionne.

Lors de la connexion, Tailscale enregistre la machine dans le tailnet et lui attribue sa propre identité, notamment une clé de nœud. Le service peut ensuite redémarrer et se reconnecter sans réutiliser la clé d’inscription.

Vous pouvez donc :

  • supprimer la clé du terminal, d’un script ou d’un gestionnaire de secrets ;
  • révoquer la clé dans la console d’administration Tailscale si elle ne doit plus servir ;
  • conserver une clé uniquement si elle est utilisée pour inscrire automatiquement d’autres machines.

Attention : cela ne signifie pas que la machine est définitivement indépendante de l’authentification. Selon la configuration du tailnet, elle peut devoir se réauthentifier si sa clé de nœud expire, si la machine est supprimée du tailnet ou si vous exécutez :

1
sudo tailscale logout

Dans ce dernier cas, il faudra généralement exécuter à nouveau :

1
sudo tailscale up

Pour une machine serveur, vérifiez simplement que l’état reste connecté après un redémarrage :

1
2
sudo systemctl restart tailscaled
tailscale status

Par prudence, si la clé d’inscription a été exposée dans un historique ou un script, révoquez-la même si elle est à usage unique ou déjà utilisée.

Installer application tailscale

Debian
La méthode officielle installe le dépôt Tailscale puis le paquet adapté à votre version de Debian :

1
2
3
4
5
6
7
8
9
10
# Script officiel
curl -fsSL https://tailscale.com/install.sh | sh

# Démarrer
sudo systemctl enable --now tailscaled
sudo tailscale up

# Vérifier
tailscale status
tailscale ip -4

Arch Linux
Installer le paquet depuis les dépôts officiels

1
2
3
4
# Arch
sudo pacman -S tailscale
sudo systemctl enable --now tailscaled
sudo tailscale up

Alpine linux Installer le paquet depuis les dépôts officiels

1
2
3
4
sudo apk add -U tailscale
sudo rc-update add tailscale
# /etc/init.d/tailscale start
sudo service tailscale start

Activer tailscale sur les machines clientes

Vous avez besoin d’une clé pour valider taiscale

Sur le client, installer Tailscale, puis utiliser l’URL Headscale et la clé créée :

1
2
3
sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=COLLER_ICI_LA_CLE

Sur le VPS Headscale, vérifier l’enregistrement :

1
sudo headscale nodes list

list

1
2
3
4
5
ID | Hostname | Name  | MachineKey | NodeKey | User | Tags | IP addresses      | Ephemeral | Last seen           | Expiration | Connected | Expired
2  | alder    | alder | [3Ccdq]    | [qQ/Is] | yick |      | 100.64.0.2        | false     | 2026-09-26 12:11:30 | N/A        | online    | no     
   |          |       |            |         |      |      | fd7a:115c:a1e0::2 |           |                     |            |           |        
3  | pc1      | pc1   | [zvVf2]    | [2OpsD] | yann |      | 100.64.0.3        | false     | 2026-09-26 12:32:08 | N/A        | online    | no     
   |          |       |            |         |      |      | fd7a:115c:a1e0::3 |           |                     |            |           |        

Puis, côté client :

1
2
tailscale status
tailscale netcheck
1
2
3
4
5
6
7
8
9
10
11
12
100.64.0.1  ouestline  yann  linux  -  

Report:
	* Time: 2026-09-26 13:43:53.886284382+02:00
	* UDP: true
	* IPv4: yes, 83.228.219.134:40800
	* IPv6: yes, [2001:1600:18:102::c9]:44340
	* MappingVariesByDestIP: 
	* PortMapping: 
	* Nearest DERP: Xoyize DERP
	* DERP latency:
		- xoyize: 9ms     (Xoyize DERP)

Ajouter ensuite le serveur Debian NFS et un seul client Arch, avant tout le reste.

  • Vérifier d’abord SSH via le nom mesh ou l’adresse overlay
  • puis tester le montage NFS.

Inscription des machines clientes

Cet ordre isole les problèmes réseau, ACL et NFS.

  1. pc1 : premier poste d’administration et premier client NFS.
  2. cwwk : serveur NFS.
  3. e6230w : second client NFS.
  4. skrime : VPS IPv6-only.
  5. proxmox et vm105.
  6. Les VPS publics restants : yannig, xoyaz, yannir, yiak.
  7. Android, taggé seulement tag:mobile.

Créer les clés

1
2
sudo headscale preauthkeys create --help
sudo headscale nodes tag --help

Inscription PC1

Comme pc1 est un poste humain et non un serveur, il peut être enregistré puis taggé depuis Headscale. Sur sxb :

1
2
3
sudo headscale preauthkeys create \
  --user 1 \         # yann --> ID=1
  --expiration 1h

Sur pc1 :

1
2
3
4
5
6
sudo pacman -Syu tailscale
sudo systemctl enable --now tailscaled

sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=COLLE_LA_CLE

Sur sxb, liste les nœuds et relève l’ID de PC1 :

1
sudo headscale nodes list

Puis tague-le :

1
2
3
sudo headscale nodes tag \
  -i ID_PC1 \
  -t tag:admin,tag:nfs-client

Ces tags donnent à PC1 ses droits de SSH d’administration et de montage NFS.

Inscription cwwk

Crée une clé à usage court, avec les tags serveur et NFS :

1
2
3
4
sudo headscale preauthkeys create \
  --user yann \
  --expiration 1h \
  --tags tag:server,tag:nfs-server

Sur cwwk :

1
2
3
4
5
6
7
sudo apt update
sudo apt install -y tailscale
sudo systemctl enable --now tailscaled

sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=COLLE_LA_CLE

Puis, sur sxb :

1
sudo headscale nodes list

Tu dois voir cwwk avec les tags tag:server et tag:nfs-server. Sinon :

1
2
3
sudo headscale nodes tag \
  -i ID_CWWK \
  -t tag:server,tag:nfs-server

Ne lance pas --advertise-tags en plus si la clé porte déjà ces tags : cela évite la confusion.

Inscription e6230w

Sur sxb :

1
2
3
4
sudo headscale preauthkeys create \
  --user yann \
  --expiration 1h \
  --tags tag:admin,tag:nfs-client

Sur le laptop CachyOS :

1
2
3
4
5
6
sudo pacman -Syu tailscale
sudo systemctl enable --now tailscaled

sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=COLLE_LA_CLE

Puis valide :

1
2
3
tailscale status
tailscale netcheck
tailscale ping cwwk

Sur le VPS :

1
sudo headscale nodes list

Inscription skrime IPv6-only

Cette machine est importante parce qu’elle valide l’objectif qui t’avait bloqué avec Nebula.

Sur sxb, crée une clé serveur :

1
2
3
4
sudo headscale preauthkeys create \
  --user yann \
  --expiration 1h \
  --tags tag:server

Sur skrime :

1
2
3
4
5
6
7
8
9
10
11
12
13
getent ahosts headscale.xoyize.xyz
curl -6 -I https://headscale.xoyize.xyz

sudo apt update
sudo apt install -y tailscale
sudo systemctl enable --now tailscaled

sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=COLLE_LA_CLE

tailscale netcheck
tailscale status

La commande getent ahosts doit au minimum faire apparaître l’IPv6 2001:41d0:404:200::7ec4, et curl -6 doit réussir avant même l’installation du client. tailscale netcheck indiquera ensuite si UDP et IPv6 sont utilisables depuis ce VPS.

Modification d’un client

Modifier le client host tiny, ID=13

  • Renommer tiny en proxtin
  • Ajouter le tag nfs-client aux tags existants tag:server
1
2
sudo headscale nodes tag -i 13 \
-t  tag:server,tag:nfs-client

Inscription clients restants

Pour chaque serveur Debian, crée une clé courte et taggée tag:server :

1
2
3
4
sudo headscale preauthkeys create \
  --user yann \
  --expiration 1h \
  --tags tag:server

Puis, sur chaque serveur :

1
2
3
4
5
6
7
sudo apt update
sudo apt install -y tailscale
sudo systemctl enable --now tailscaled

sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=COLLE_LA_CLE

Répète avec une nouvelle clé pour chaque machine. À la fin de chaque enrôlement :

1
sudo headscale nodes list

Tu pourras distinguer les hôtes par leurs noms Tailscale ; ajuste le hostname Linux avant l’inscription si nécessaire :

1
sudo hostnamectl set-hostname vm105

Fais-le avant tailscale up, ou réinscris/renomme proprement le nœud s’il apparaît sous un nom inutilisable.

Inscription (procédure détaillée)

La machine

  • Proxmox tiny
  • IP: 192.168.0.216
  • SSH port: 55216
  • Utilisateur: tin

1-Headscale serveur

Créer utilisateur et clé inscription

1
2
3
4
# Utilisateur
sudo headscale users create tin
# relever ID
sudo headscale users list |grep tin # User ID 11

Créer clé inscription valide 3h

1
2
3
sudo headscale preauthkeys create \
  --user 11 \
  --expiration 3h

La clé générée

1
hskey-auth-itSb6Ebk6uhY-MJ0yBYh5JcE_zf6nE0J6XI1nsrMQs2Xy3Jc-ic35NES_9QMq-CJ7Ay1hROgXFGZA

2-Client Proxmox tiny

Se connecter sur la machine via SSH

Installer tailscale

1
2
3
4
# Debian
curl -fsSL https://tailscale.com/install.sh | sh
# Activer et démarrer le service
sudo systemctl enable --now tailscaled

Utiliser l’URL Headscale et la clé

1
2
3
sudo tailscale up \
  --login-server=https://headscale.xoyize.xyz \
  --authkey=hskey-auth-itSb6Ebk6uhY-MJ0yBYh5JcE_zf6nE0J6XI1nsrMQs2Xy3Jc-ic35NES_9QMq-CJ7Ay1hROgXFGZA

Aucune erreur ne doit s’afficher

*3-Headscale serveur *

Relever ID nouveau client tiny

1
sudo headscale nodes list # ID 13

Ajouter le tag server sur le nouveau client

1
2
3
# 13  | tiny
sudo headscale nodes tag -i 13 \
  -t tag:server

Les machines qui peuvent administrer les clients tailscale via ssh sont taggés admin Modifier le fichier /etc/headscale/policy.hujson pour y ajouter le client tailscale avec le port dédié ssh Exemple avec “proxmox tiny”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  "acls": [
    // Les postes, une fois marqués tag:admin,
    // peuvent administrer les serveurs via leurs ports SSH existants.
    {
      "action": "accept",
      "src": ["tag:admin"],
      "dst": [
        "tag:server:51002", // skrime vm9471
        "tag:server:55045", // xoyaz
        "tag:server:55134", // yiak
        "tag:server:55149", // yannir
        "tag:server:55215", // proxmox
        "tag:server:55229", // vm105
        "tag:server:55240", // yannig
        "tag:server:56230", // e6230
        "tag:server:55216", // proxmox tiny
        "tag:nfs-server:55205" // cwwk alder
      ]
    },

Valider la modification

1
sudo headscale policy check -f /etc/headscale/policy.hujson

Recharger

1
sudo systemctl reload headscale

Relever IP tailscale0: 100.64.0.12 fd7a:115c:a1e0::c

4-Machine tag admin (SSH)

Tester la connexion ssh par le réseau privé depuis une machine tag admin

1
2
ssh tin@100.64.0.12 -p 55216 -i /home/yann/.ssh/m700-ed25519
ssh tin@fd7a:115c:a1e0::c -p 55216 -i /home/yann/.ssh/m700-ed25519

Attribution manuelle des tags

Après qu’une machine est enregistrée avec son utilisateur correspondant, relèver son ID :

1
sudo headscale nodes list

Puis appliquer son ou ses tags depuis le VPS.

Pour cwwk :

1
2
3
sudo headscale nodes tag \
  -i ID_CWWK \
  -t tag:server,tag:nfs-server

Pour e6230w :

1
2
3
sudo headscale nodes tag \
  -i ID_E6230W \
  -t tag:admin,tag:nfs-client

Pour pc1 :

1
2
3
sudo headscale nodes tag \
  -i ID_PC1 \
  -t tag:admin,tag:nfs-client

Pour chacun des autres serveurs :

1
2
3
sudo headscale nodes tag \
  -i ID_SERVEUR \
  -t tag:server

Pour Android :

1
2
3
sudo headscale nodes tag \
  -i ID_ANDROID \
  -t tag:mobile

Puis contrôle le résultat :

1
sudo headscale nodes list

Ne jamais donner le tag tag:nfs-client à Android, skrime, aux VPS publics ou au VPS Headscale/DERP.

Annexe

Serveur cwwk - Verrouiller NFS

Adresses IP sur cwwk

1
2
3
4
5
6
7
8
23: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 qdisc fq_codel state UNKNOWN group default qlen 500
    link/none 
    inet 100.64.0.2/32 scope global tailscale0
       valid_lft forever preferred_lft forever
    inet6 fd7a:115c:a1e0::2/128 scope global 
       valid_lft forever preferred_lft forever
    inet6 fe80::f61e:fceb:e27b:233f/64 scope link stable-privacy proto kernel_ll 
       valid_lft forever preferred_lft forever

Lorsque pc1, cwwk et e6230w apparaissent dans headscale nodes list

1
2
3
4
5
6
7
8
ID | Hostname | Name   | MachineKey | NodeKey | User           | Tags           | IP addresses      | Ephemeral | Last seen           | Expiration | Connected | Expired
2  | alder    | alder  | [3Ccdq]    | [qQ/Is] | tagged-devices | tag:nfs-server | 100.64.0.2        | false     | 2026-09-26 14:29:55 | N/A        | online    | no     
   |          |        |            |         |                | tag:server     | fd7a:115c:a1e0::2 |           |                     |            |           |        
3  | pc1      | pc1    | [zvVf2]    | [2OpsD] | tagged-devices | tag:admin      | 100.64.0.3        | false     | 2026-09-26 14:29:54 | N/A        | online    | no     
   |          |        |            |         |                | tag:nfs-client | fd7a:115c:a1e0::3 |           |                     |            |           |        
4  | e6230    | e6230  | [fwlWZ]    | [ZHTpC] | tagged-devices | tag:admin      | 100.64.0.4        | false     | 2026-09-26 14:29:54 | N/A        | online    | no     
   |          |        |            |         |                | tag:nfs-client | fd7a:115c:a1e0::4 |           |                     |            |           |        
5  | vm9471   | vm9471 | [LmbOQ]    | [PonWl] | tagged-devices | tag:server     | 100.64.0.5        | false     | 2026-09-26 14:29:54 | N/A        | online    | no     

relèver les IP mesh de pc1 et e6230w

1
2
3
cwwk    100.64.0.2
pc1     100.64.0.3
e6230w  100.64.0.4

Sur cwwk, dans /etc/exports :

1
2
3
/sharenfs \
  100.64.0.0/24(rw,sync,no_subtree_check,root_squash,sec=sys) \
  fd7a:115c:a1e0::/48(rw,sync,no_subtree_check,root_squash,sec=sys)

Autoriser directement les sous-réseaux du tailnet (100.64.0.0/10 et fd7a:115c:a1e0::/48) au lieu de lister les machines une par une. Le contrôle fin reste fait par les ACL Headscale et le tag nfs-client.

Le montage fonctionne maintenant sur proxtin ? Appliquer :

1
2
sudo exportfs -rav
sudo exportfs -v

Puis protèger le port NFS localement :

1
2
3
4
5
6
7
sudo ufw allow in on tailscale0 to any port 2049 proto tcp \
  comment 'NFSv4 private mesh only'

sudo ufw deny in 2049/tcp \
  comment 'Block NFS outside mesh'

sudo ufw status numbered

Status

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Status: active

To                         Action      From
--                         ------      ----
443                        ALLOW       Anywhere                  
DNS                        ALLOW       Anywhere                  
55205/tcp                  ALLOW       Anywhere                  
Anywhere                   ALLOW       192.168.0.0/24            
Anywhere                   ALLOW       192.168.10.0/24           
Anywhere                   DENY        132.196.69.70             
2049/tcp on tailscale0     ALLOW       Anywhere                   # NFSv4 private mesh only
2049/tcp                   DENY        Anywhere                   # Block NFS outside mesh
443 (v6)                   ALLOW       Anywhere (v6)             
DNS (v6)                   ALLOW       Anywhere (v6)             
55205/tcp (v6)             ALLOW       Anywhere (v6)             
2049/tcp (v6) on tailscale0 ALLOW       Anywhere (v6)              # NFSv4 private mesh only
2049/tcp (v6)              DENY        Anywhere (v6)              # Block NFS outside mesh

NFS est donc protégé par :

  • L’absence d’exposition Internet volontaire.
  • L’ACL Headscale : seuls les nœuds avec tag:nfs-client atteignent TCP/2049.
  • Le pare-feu cwwk : TCP/2049 accepté seulement par tailscale0.
  • /etc/exports : seules les IP mesh de PC1 et e6230w peuvent monter l’export.
  • root_squash : root sur un client ne devient pas root sur cwwk.

/etc/exports contrôle explicitement quels clients peuvent monter un chemin, et root_squash fait partie des protections NFS usuelles.[man7][man7]

VPN Mullvad, Tailscale et NFS

Problème client tailscale si le VPN Mullvad est actif sur les architectures “Bureau”

PV1 Archlinux: Tests pour mettre en évidence l’erreur

1
2
3
4
5
tailscale ping yiak # direct ou "via DERP(...)" ?  
ping -4 -c2 100.64.0.6 # IPv4 tailnet  
ping -6 -c2 fd7a:115c:a1e0::6 # IPv6 tailnet  
dig headscale.xoyize.xyz  
dig @100.100.100.100 headscale.xoyize.xyz
OK sans VPN

, NOK avec VPN

Ces tests confirment que Mullvad bloque le tailnet.

Insérer des règles directement dans les chaînes de Mullvad fonctionne (autoriser l’entrée et la sortie sur l’interface tailscale), mais Mullvad les efface à la reconnexion (règles non persistantes). La méthode qui survit aux reconnexions utilise une table nftables séparée qui pose sur le trafic Tailscale la marque d’exclusion de Mullvad, ct mark 0x00000f41 (source). Voici le principe, à adapter : mullvad and tailscale coexisting or hello nftables +2

/etc/nftables.d/mullvad-tailscale.nft

Correctif, créer le fichier /etc/nftables.d/mullvad-tailscale.nft

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo mkdir -p /etc/nftables.d
sudo tee /etc/nftables.d/mullvad-tailscale.nft <<'EOF'
table inet excludeTraffic {}
delete table inet excludeTraffic
table inet excludeTraffic {
  chain excludeOutgoing {
    type route hook output priority -100; policy accept;
    ip  daddr 100.64.0.0/10       ct mark set 0x00000f41 meta mark set 0x6d6f6c65
    ip6 daddr fd7a:115c:a1e0::/48 ct mark set 0x00000f41 meta mark set 0x6d6f6c65
  }
  chain allowIncoming {
    type filter hook input priority -100; policy accept;
    iifname "tailscale0" ct mark set 0x00000f41 meta mark set 0x6d6f6c65
  }
}
EOF

Test

1
nft -f /etc/nftables.d/mullvad-tailscale.nft

Chargez-la au démarrage avec une unité systemd ou nftables.service. La valeur 0x6c7873 vient de ma mémoire de la doc de Mullvad, alors que 0x00000f41 est confirmée par ma recherche : vérifiez les deux sur la page « split tunneling » de Mullvad, ou reprenez un des dépôts qui documentent ce montage (par exemple alessio-ds/tailscale-mullvad-linux, qui cible Arch). Après chargement, sudo nft list ruleset doit montrer la table, et tailscale ping un-pair doit répondre alors que Mullvad est connecté.

/etc/systemd/system/tailscale-mullvad.service

Correctif persistant, créer un service /etc/systemd/system/tailscale-mullvad.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sudo tee /etc/systemd/system/tailscale-mullvad.service <<'EOF'
[Unit]
Description=Exclusion du tailnet du kill switch Mullvad
After=network-online.target tailscaled.service
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/nft -f /etc/nftables.d/mullvad-tailscale.nft
ExecStop=/usr/bin/nft delete table inet excludeTraffic

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now tailscale-mullvad.service
systemctl is-active tailscale-mullvad.service

Le fichier commence par table ... {} suivi de delete table, donc le service peut être relancé autant de fois que vous voulez sans erreur.

/etc/systemd/system/tailscale-ready.service

Attendre le montage jusqu’à ce que le tailnet soit vraiment prêt
Créer /etc/systemd/system/tailscale-ready.service

1
2
3
4
5
6
7
8
9
10
11
12
13
[Unit]
Description=Attente que le tailnet soit opérationnel
After=tailscaled.service tailscale-mullvad.service
Requires=tailscaled.service

[Service]
Type=oneshot
RemainAfterExit=yes
TimeoutStartSec=180
ExecStart=/bin/sh -c 'until tailscale ping --c 1 --timeout 3s 100.64.0.2 >/dev/null 2>&1; do sleep 3; done'

[Install]
WantedBy=multi-user.target

Avec ça, un accès précoce à /sharenfs attendra que le serveur soit joignable au lieu d’échouer. Après un redémarrage, systemctl status tailscale-ready sharenfs.mount devrait montrer les deux en succès.

NFS /etc/fstab

Montage fstab pour tenir compte du délai de la liaison avec le serveur headscale.xoyize.xyz

1
100.64.0.2:/sharenfs /sharenfs nfs4 nofail,_netdev,x-systemd.automount,x-systemd.requires=tailscale-ready.service,x-systemd.requires=tailscale-mullvad.service,x-systemd.mount-timeout=30s,x-systemd.idle-timeout=600,nfsvers=4.2 0 0

Script headscale-watch

💡 Script serveur headscale-watch.sh avec son unité, son minuteur et un fichier d'environnement type

Préalable Sur le serveur ntfy , ajout d’une nouvelle notification

1
sudo ntfy access yann headscale rw

Si tout est OK

1
2
3
4
5
granted read-write access to topic headscale

user yann (role: user, tier: none)
- read-write access to topic yan_infos
- read-write access to topic headscale

Installation sur le VPS

En mode su

1
2
3
4
5
6
7
apt install jq -y                      # sqlite3 en option (contrôle d'intégrité de la base)
install -m 755 headscale-watch.sh /usr/local/sbin/
install -m 644 headscale-watch.service headscale-watch.timer /etc/systemd/system/
install -m 600 headscale-watch.env /etc/headscale-watch.env   # puis éditer NTFY_*
headscale-watch.sh --print --no-notify    # premier essai, sans rien envoyer
headscale-watch.sh --test-notify          # message de test ntfy
systemctl daemon-reload && systemctl enable --now headscale-watch.timer

Voir en annexe, le contenu des fichiers

Contrôles

  • Service : état, redémarrages automatiques, /health, configtest, erreurs du journal sur 24 h.
  • Nœuds (nodes list -o json) :
    • nœuds absents depuis plus de 7 jours ;
    • clés de nœud expirées ou expirant sous 14 jours ;
    • routes annoncées mais non approuvées ;
    • noms en double.
  • Clés : clés de pré-authentification et clés API, comptées sans jamais lire leur valeur.
  • Serveur : certificat TLS, disques, quick_check SQLite, âge de la dernière sauvegarde (si BACKUP_GLOB est défini), NTP, mises à jour de sécurité, ports inattendus (si EXPECTED_PORTS est défini).
  • Version de Headscale : la comparaison avec la dernière publiée est désactivée par défaut, car l’API de GitHub n’a pas d’IPv6.

Alertes ntfy

  • L’envoi reprend votre exemple : --ipv4, X-Email, Authorization: Bearer, Title, priorité et message. Les tags sont ajoutés.
  • Un message part quand l’ensemble des contrôles en alerte change, avec un rappel après 24 h si le problème persiste. Quand tout redevient normal, un message « retour à la normale » part en priorité basse.
  • Priorités : critique = high, avertissement = default, retour à la normale = low. Si l’envoi échoue, l’état n’est pas enregistré et le script retente au passage suivant.
  • Les titres sont en ASCII, car les en-têtes HTTP avec accents posent problème.
  • Si le VPS est IPv6 only, mettez NTFY_CURL_OPTS=--ipv6 ou laissez-le vide, car votre --ipv4 ne passerait pas.
  • Votre exemple contient un backtick parasite après « Contenu du message » : dans bash, il ouvrirait une substitution de commande.
  • NTFY_EMAIL_LEVEL=CRIT n’envoie l’e-mail que pour les alertes critiques.

Lien avec l’audit : le rapport est écrit dans /var/lib/headscale-watch/report.json, sans secret. hs-audit.sh l’affiche maintenant dans une section « Surveillance » de la partie serveur, avec les seuls contrôles non OK. Ce changement ne s’applique qu’après remplacement de votre copie par la version ci-dessus.

Les noms de nœuds apparaissent dans les messages ntfy. Si votre serveur ntfy est public, utilisez un sujet difficile à deviner ou activez l’authentification (votre jeton Bearer le fait déjà).

Annexe

headscale-watch.env.example

# /etc/headscale-watch.env  (chmod 600, propriétaire root) — format KEY=valeur, sans guillemets
NTFY_URL=https://ntfy.example.org/headscale
NTFY_TOKEN=tk_xxxxxxxxxxxxxxxx
# Optionnel : relais e-mail ntfy (en-tête X-Email) ; NTFY_EMAIL_LEVEL=CRIT pour les seules alertes critiques
NTFY_EMAIL=moi@example.org
NTFY_EMAIL_LEVEL=ALL
# Options passées à curl (défaut --ipv4 ; mettre --ipv6 ou vide si le VPS est en IPv6 seul)
NTFY_CURL_OPTS=--ipv4

# Seuils (valeurs par défaut)
#STALE_DAYS=7
#KEY_WARN_DAYS=14
#TLS_WARN_DAYS=21
#TLS_CRIT_DAYS=7
#DISK_WARN=80
#DISK_CRIT=90
#REMIND_HOURS=24

# Contrôles facultatifs
#HS_HEALTH_URL=http://127.0.0.1:8080/health
#DB_PATH=/var/lib/headscale/db.sqlite
#BACKUP_GLOB=/var/backups/headscale/*
#BACKUP_MAX_DAYS=2
#EXPECTED_PORTS=22 80 443 3478
#STRICT_KEYS=0
#CHECK_LATEST=0

headscale-watch.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
#!/usr/bin/env bash
# headscale-watch.sh — surveillance locale d'un serveur Headscale (Debian)
# Produit un rapport JSON (sans secrets) et envoie des alertes ntfy lors des changements d'état.
# À exécuter en root (CLI headscale, journal, base). Prévu pour tourner via un minuteur systemd.
#
# Usage : headscale-watch.sh [--print] [--no-notify] [--test-notify] [--exit-code]
#   --print        affiche le rapport lisible
#   --no-notify    n'envoie rien et ne modifie pas l'état des alertes
#   --test-notify  envoie un message de test ntfy et s'arrête
#   --exit-code    code retour 0=OK 1=WARN 2=CRIT (sinon toujours 0)
#
# Configuration : variables d'environnement (voir headscale-watch.env.example)
#   NTFY_URL (obligatoire pour alerter), NTFY_TOKEN, NTFY_EMAIL, NTFY_CURL_OPTS (défaut --ipv4),
#   NTFY_EMAIL_LEVEL (ALL par défaut, ou CRIT), REMIND_HOURS (24), STALE_DAYS (7), KEY_WARN_DAYS (14),
#   TLS_WARN_DAYS (21), TLS_CRIT_DAYS (7), DISK_WARN (80), DISK_CRIT (90), HS_URL, HS_HEALTH_URL,
#   DB_PATH, BACKUP_GLOB, BACKUP_MAX_DAYS (2), EXPECTED_PORTS, CHECK_LATEST (0), STRICT_KEYS (0)

. "/etc/headscale-watch.env"

set -u
export LC_ALL=C.UTF-8

STATE_DIR="${STATE_DIRECTORY:-${STATE_DIR:-/var/lib/headscale-watch}}"
STALE_DAYS="${STALE_DAYS:-7}"; KEY_WARN_DAYS="${KEY_WARN_DAYS:-14}"
TLS_WARN_DAYS="${TLS_WARN_DAYS:-21}"; TLS_CRIT_DAYS="${TLS_CRIT_DAYS:-7}"
DISK_WARN="${DISK_WARN:-80}"; DISK_CRIT="${DISK_CRIT:-90}"
REMIND_HOURS="${REMIND_HOURS:-24}"
HS_CONFIG="${HS_CONFIG:-/etc/headscale/config.yaml}"
HS_HEALTH_URL="${HS_HEALTH_URL:-http://127.0.0.1:8080/health}"
DB_PATH="${DB_PATH:-/var/lib/headscale/db.sqlite}"
BACKUP_MAX_DAYS="${BACKUP_MAX_DAYS:-2}"
CHECK_LATEST="${CHECK_LATEST:-0}"; STRICT_KEYS="${STRICT_KEYS:-0}"
NTFY_EMAIL_LEVEL="${NTFY_EMAIL_LEVEL:-ALL}"

PRINT=0; NONOTIFY=0; TESTNOTIFY=0; EXITCODE=0
for o in "$@"; do
  case "$o" in
    --print) PRINT=1 ;; --no-notify) NONOTIFY=1 ;; --test-notify) TESTNOTIFY=1 ;; --exit-code) EXITCODE=1 ;;
    -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
    *) echo "option inconnue : $o" >&2; exit 3 ;;
  esac
done

[ "$(id -u)" -eq 0 ] || { echo "À lancer en root" >&2; exit 3; }
command -v jq >/dev/null || { echo "jq requis (apt install jq)" >&2; exit 3; }
HSCMD=(headscale); [ -r "$HS_CONFIG" ] && HSCMD+=(-c "$HS_CONFIG")
HOST=$(hostname -s); NOW=$(date +%s)

mkdir -p "$STATE_DIR"; chmod 750 "$STATE_DIR"
exec 9>"$STATE_DIR/.lock"; flock -n 9 || exit 0

CHK=$(mktemp); trap 'rm -f "$CHK" "$CHK.json"' EXIT
add() { local m="${3:-}"; m="${m//$'\t'/ }"; m="${m//$'\n'/ }"; printf '%s\t%s\t%s\n' "$1" "$2" "$m" >>"$CHK"; }

# ---------------------------------------------------------------- ntfy
notify() {  # $1 titre (ASCII)  $2 message  $3 priorité  $4 tags  $5 niveau (CRIT|WARN|OK)
  [ -n "${NTFY_URL:-}" ] || { echo "NTFY_URL non défini : pas de notification" >&2; return 1; }
  local -a args
  # shellcheck disable=SC2206
  args=(${NTFY_CURL_OPTS---ipv4})
  args+=(-sS -m 15 -H "Title: $1" -H "Priority: $3" -H "Tags: $4")
  if [ -n "${NTFY_EMAIL:-}" ] && { [ "$NTFY_EMAIL_LEVEL" = "ALL" ] || [ "$5" = "CRIT" ]; }; then
    args+=(-H "X-Email: $NTFY_EMAIL")
  fi
  [ -n "${NTFY_TOKEN:-}" ] && args+=(-H "Authorization: Bearer $NTFY_TOKEN")
  curl "${args[@]}" -d "$2" "$NTFY_URL" >/dev/null || { echo "échec de l'envoi ntfy" >&2; return 1; }
}

if [ "$TESTNOTIFY" = 1 ]; then
  notify "Headscale $HOST : test" "Message de test de headscale-watch ($(date '+%F %H:%M'))" low white_check_mark OK
  exit $?
fi

# ---------------------------------------------------------------- contrôles
check_service() {
  if systemctl is-active --quiet headscale; then
    add service OK "actif depuis $(systemctl show headscale -p ActiveEnterTimestamp --value 2>/dev/null)"
    local n; n=$(systemctl show headscale -p NRestarts --value 2>/dev/null); n="${n:-0}"
    if [ "$n" -ge 3 ] 2>/dev/null; then add restarts WARN "$n redémarrages automatiques depuis le dernier démarrage manuel"
    else add restarts OK "$n redémarrage(s) automatique(s)"; fi
  else
    add service CRIT "service headscale inactif ($(systemctl is-active headscale 2>&1))"
  fi
}

check_health() {
  if curl -fsS -m 5 -o /dev/null "$HS_HEALTH_URL" 2>/dev/null; then add health OK "$HS_HEALTH_URL répond"
  else add health CRIT "$HS_HEALTH_URL ne répond pas"; fi
}

check_logs() {
  local n; n=$(journalctl -u headscale -p err --since "24 hours ago" -q --no-pager 2>/dev/null | wc -l)
  if [ "$n" -gt 0 ]; then add logs WARN "$n erreur(s) dans le journal sur 24 h"; else add logs OK "aucune erreur dans le journal sur 24 h"; fi
}

check_configtest() {
  if timeout 20 "${HSCMD[@]}" configtest >/dev/null 2>&1; then add configtest OK "configuration valide"
  else add configtest CRIT "headscale configtest échoue"; fi
}

check_tls() {
  local url="${HS_URL:-}" host port end epoch days
  [ -n "$url" ] || url=$(grep -m1 -E '^\s*server_url:' "$HS_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d "\"'")
  case "$url" in https://*) ;; *) add tls INFO "server_url non HTTPS ou introuvable : contrôle ignoré"; return ;; esac
  host="${url#https://}"; host="${host%%/*}"; port=443
  case "$host" in *:*) port="${host##*:}"; host="${host%%:*}" ;; esac
  end=$(echo | timeout 12 openssl s_client -connect "$host:$port" -servername "$host" 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
  [ -n "$end" ] || { add tls WARN "certificat TLS illisible"; return; }
  epoch=$(date -d "$end" +%s 2>/dev/null) || { add tls WARN "date du certificat illisible"; return; }
  days=$(( (epoch - NOW) / 86400 ))
  if   [ "$days" -lt "$TLS_CRIT_DAYS" ]; then add tls CRIT "certificat TLS : $days j restants"
  elif [ "$days" -lt "$TLS_WARN_DAYS" ]; then add tls WARN "certificat TLS : $days j restants"
  else add tls OK "certificat TLS valide encore $days j"; fi
}

check_disk() {
  local p id pct
  for p in / "$(dirname "$DB_PATH")"; do
    [ -d "$p" ] || continue
    pct=$(df --output=pcent "$p" 2>/dev/null | tail -1 | tr -dc 0-9); [ -n "$pct" ] || continue
    id="disk_$(echo "$p" | tr '/' '_')"
    if   [ "$pct" -ge "$DISK_CRIT" ]; then add "$id" CRIT "$p occupé à $pct %"
    elif [ "$pct" -ge "$DISK_WARN" ]; then add "$id" WARN "$p occupé à $pct %"
    else add "$id" OK "$p occupé à $pct %"; fi
  done
}

check_db() {
  [ -f "$DB_PATH" ] || { add db INFO "base SQLite introuvable ($DB_PATH) : contrôle ignoré"; return; }
  local size r; size=$(du -h "$DB_PATH" | cut -f1)
  if ! command -v sqlite3 >/dev/null; then add db INFO "base de $size (sqlite3 absent : pas de contrôle d'intégrité)"; return; fi
  r=$(timeout 60 sqlite3 -readonly "$DB_PATH" 'PRAGMA quick_check;' 2>&1 | head -1)
  if [ "$r" = "ok" ]; then add db OK "base de $size, quick_check ok"; else add db CRIT "quick_check de la base : $r"; fi
}

check_backup() {
  [ -n "${BACKUP_GLOB:-}" ] || return 0
  local f age
  # shellcheck disable=SC2012,SC2086
  f=$(ls -t $BACKUP_GLOB 2>/dev/null | head -1)
  [ -n "$f" ] || { add backup WARN "aucune sauvegarde trouvée ($BACKUP_GLOB)"; return; }
  age=$(( (NOW - $(stat -c %Y "$f")) / 86400 ))
  if [ "$age" -gt "$BACKUP_MAX_DAYS" ]; then add backup WARN "dernière sauvegarde il y a $age j"; else add backup OK "dernière sauvegarde il y a $age j"; fi
}

JQ_TS='def ts: if type=="object" then ((.seconds // 0) | tonumber) elif type=="number" then . elif type=="string" then (try fromdateiso8601 catch 0) else 0 end;'

check_nodes() {
  local json
  json=$(timeout 30 "${HSCMD[@]}" nodes list -o json 2>/dev/null) && [ -n "$json" ] \
    || { add nodes WARN "impossible de lister les nœuds (headscale nodes list)"; return; }
  printf '%s' "$json" | jq -r --argjson now "$NOW" --argjson stale "$STALE_DAYS" --argjson warn "$KEY_WARN_DAYS" "$JQ_TS"'
    def lst: map(.name) as $l | ($l[:5] | join(", ")) + (if ($l|length) > 5 then " (+\(($l|length)-5))" else "" end);
    (if type=="array" then . else (.nodes // []) end)
    | map({name: (.given_name // .name // "?"), rname: (.name // ""), online: (.online // false),
           seen: (.last_seen | ts), exp: (.expiry | ts),
           pend: (((.available_routes // .subnet_routes // []) - (.approved_routes // [])) | length)}) as $n
    | "nodes\tINFO\t\($n|length) nœud(s), \($n|map(select(.online))|length) en ligne",
      (($n|map(select((.online|not) and .seen > 0 and ($now - .seen) > ($stale*86400)))) as $s
        | if ($s|length) > 0 then "nodes_stale\tWARN\thors ligne depuis plus de \($stale) j : \($s|lst)"
          else "nodes_stale\tOK\taucun nœud absent depuis plus de \($stale) j" end),
      (($n|map(select(.exp > 0 and .exp <= $now))) as $e
        | if ($e|length) > 0 then "nodes_expired\tWARN\tclé de nœud expirée : \($e|lst)"
          else "nodes_expired\tOK\taucune clé de nœud expirée" end),
      (($n|map(select(.exp > $now and (.exp - $now) < ($warn*86400)))) as $e
        | if ($e|length) > 0 then "nodes_expiring\tWARN\tclé expirant dans moins de \($warn) j : \($e|lst)"
          else "nodes_expiring\tOK\taucune expiration proche" end),
      (($n|map(select(.pend > 0))) as $p
        | if ($p|length) > 0 then "nodes_routes\tWARN\troutes annoncées non approuvées : \($p|lst)"
          else "nodes_routes\tOK\taucune route en attente d'"'"'approbation" end),
      (($n|group_by(.rname)|map(select(length > 1 and .[0].rname != ""))) as $d
        | if ($d|length) > 0 then "nodes_dupes\tWARN\tnoms en double : \($d|map(.[0].rname)|join(", "))"
          else "nodes_dupes\tOK\tpas de doublon de nom" end)' >>"$CHK" 2>/dev/null \
    || add nodes WARN "JSON des nœuds illisible"
}

preauth_json() {
  local out u all='[]'
  if out=$(timeout 20 "${HSCMD[@]}" preauthkeys list -o json 2>/dev/null) && [ -n "$out" ]; then printf '%s' "$out"; return; fi
  for u in $("${HSCMD[@]}" users list -o json 2>/dev/null | jq -r '.[]? | .name'); do
    out=$(timeout 20 "${HSCMD[@]}" preauthkeys list -u "$u" -o json 2>/dev/null) || continue
    all=$(jq -s 'add' <(printf '%s' "$all") <(printf '%s' "${out:-[]}") 2>/dev/null) || true
  done
  printf '%s' "$all"
}

check_keys() {
  preauth_json | jq -r --argjson now "$NOW" --argjson strict "$STRICT_KEYS" "$JQ_TS"'
    (if type=="array" then . else [] end)
    | map(select(((.expiration|ts) == 0) or ((.expiration|ts) > $now))) as $a
    | ($a|map(select(.reusable == true))) as $r
    | "preauth\t" + (if ($r|length) > 0 and $strict == 1 then "WARN" else "INFO" end)
      + "\t\($a|length) clé(s) de pré-authentification active(s), dont \($r|length) réutilisable(s)"' >>"$CHK" 2>/dev/null \
    || add preauth INFO "clés de pré-authentification : lecture impossible"
  timeout 20 "${HSCMD[@]}" apikeys list -o json 2>/dev/null | jq -r --argjson now "$NOW" --argjson warn "$KEY_WARN_DAYS" "$JQ_TS"'
    (if type=="array" then . else [] end) as $k
    | ($k|map(select((.expiration|ts) > 0 and ((.expiration|ts) - $now) < ($warn*86400)))|length) as $e
    | if ($k|length) == 0 then empty
      elif $e > 0 then "apikeys\tWARN\t\($e) clé(s) API expirée(s) ou expirant dans moins de \($warn) j"
      else "apikeys\tOK\t\($k|length) clé(s) API valides" end' >>"$CHK" 2>/dev/null || true
}

check_system() {
  if [ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null)" = "yes" ]; then add ntp OK "horloge synchronisée"
  else add ntp WARN "horloge non synchronisée (les expirations de clés en dépendent)"; fi
  if [ -f /var/run/reboot-required ]; then add reboot INFO "redémarrage requis"; else add reboot OK "pas de redémarrage requis"; fi
  if command -v apt-get >/dev/null; then
    local sim sec tot
    sim=$(apt-get -s dist-upgrade 2>/dev/null | grep '^Inst' || true)
    tot=$(printf '%s' "$sim" | grep -c . || true)
    sec=$(printf '%s' "$sim" | grep -ci 'security' || true)
    if [ "${sec:-0}" -gt 0 ]; then add updates WARN "$sec mise(s) à jour de sécurité en attente (total $tot)"
    else add updates OK "aucune mise à jour de sécurité en attente (total $tot)"; fi
  fi
}

check_version() {
  local cur lat
  cur=$("${HSCMD[@]}" version 2>/dev/null | head -1); cur="${cur:-inconnue}"
  if [ "$CHECK_LATEST" = 1 ]; then
    lat=$(curl --ipv4 -fsS -m 10 https://api.github.com/repos/juanfont/headscale/releases/latest 2>/dev/null | jq -r '.tag_name // empty')
    if [ -z "$lat" ]; then add version INFO "version $cur (dernière version non consultable)"
    elif [ "${lat#v}" != "${cur#v}" ]; then add version INFO "version $cur, dernière publiée : $lat"
    else add version OK "version $cur à jour"; fi
  else add version INFO "version $cur"; fi
}

check_ports() {
  [ -n "${EXPECTED_PORTS:-}" ] || return 0
  local p extra=""
  for p in $(ss -tulnH 2>/dev/null | awk '{print $5}' | grep -vE '^(127\.|\[::1\]|::1)' | sed -E 's/.*:([0-9]+)$/\1/' | sort -un); do
    case " $EXPECTED_PORTS " in *" $p "*) ;; *) extra="$extra $p" ;; esac
  done
  if [ -n "$extra" ]; then add ports WARN "ports en écoute non attendus :$extra"; else add ports OK "seuls les ports attendus sont exposés"; fi
}

check_service; check_health; check_configtest; check_logs; check_tls; check_disk; check_db; check_backup
check_nodes; check_keys; check_system; check_version; check_ports

# ---------------------------------------------------------------- rapport JSON
jq -Rn --arg host "$HOST" --arg gen "$(date -Is)" '
  [inputs | split("\t") | select(length >= 2) | {id: .[0], status: .[1], msg: (.[2] // "")}] as $c
  | {"OK":0,"INFO":0,"WARN":1,"CRIT":2} as $r
  | ($c | map($r[.status]) | max // 0) as $m
  | {host: $host, generated: $gen, overall: (["OK","WARN","CRIT"][$m]),
     counts: {crit: ($c|map(select(.status=="CRIT"))|length), warn: ($c|map(select(.status=="WARN"))|length)},
     checks: $c}' <"$CHK" >"$CHK.json"
install -m 640 "$CHK.json" "$STATE_DIR/report.json"

OVERALL=$(jq -r .overall "$CHK.json")
NCRIT=$(jq -r .counts.crit "$CHK.json"); NWARN=$(jq -r .counts.warn "$CHK.json")
BODY=$(jq -r '(.checks | map(select(.status=="CRIT")) + map(select(.status=="WARN")))[] | "[\(.status)] \(.id) : \(.msg)"' "$CHK.json" | head -c 3500)
FP=$(jq -r '[.checks[] | select(.status=="WARN" or .status=="CRIT") | "\(.id):\(.status)"] | sort | join(",")' "$CHK.json")

if [ "$PRINT" = 1 ]; then
  jq -r '"Hôte : \(.host) — \(.generated) — global : \(.overall)", (.checks[] | "  [\(.status)] \(.id) : \(.msg)")' "$CHK.json"
fi

# ---------------------------------------------------------------- alertes (sur changement ou rappel)
if [ "$NONOTIFY" != 1 ]; then
  ST="$STATE_DIR/state.json"
  PREV_FP=$(jq -r '.fp // ""' "$ST" 2>/dev/null || true)
  PREV_N=$(jq -r '.notified // 0' "$ST" 2>/dev/null || echo 0)
  SEND=""
  if [ "$FP" != "$PREV_FP" ]; then
    if [ -z "$FP" ]; then SEND=recovery; else SEND=change; fi
  elif [ -n "$FP" ] && [ $((NOW - PREV_N)) -ge $((REMIND_HOURS * 3600)) ]; then
    SEND=remind
  fi
  NOTIFIED="$PREV_N"; OKSEND=1
  if [ -n "$SEND" ]; then
    case "$SEND" in
      recovery) notify "Headscale $HOST : retour a la normale" "Plus aucune alerte (avant : ${PREV_FP:-n/a})" low white_check_mark OK || OKSEND=0 ;;
      *)
        pfx=""; [ "$SEND" = remind ] && pfx="(rappel) "
        if [ "$OVERALL" = CRIT ]; then pr=high; tg=rotating_light; lvl=CRIT; else pr=default; tg=warning; lvl=WARN; fi
        notify "${pfx}Headscale $HOST : $NCRIT CRIT, $NWARN WARN" "$BODY" "$pr" "$tg" "$lvl" || OKSEND=0 ;;
    esac
    [ "$OKSEND" = 1 ] && NOTIFIED="$NOW"
  fi
  if [ "$OKSEND" = 1 ]; then
    jq -n --arg fp "$FP" --arg o "$OVERALL" --argjson n "$NOTIFIED" '{fp:$fp, overall:$o, notified:$n}' >"$ST"
  fi
fi

if [ "$EXITCODE" = 1 ]; then case "$OVERALL" in CRIT) exit 2 ;; WARN) exit 1 ;; esac; fi
exit 0

headscale-watch.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=Surveillance du serveur Headscale (rapport + alertes ntfy)
After=headscale.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
EnvironmentFile=-/etc/headscale-watch.env
ExecStart=/usr/local/sbin/headscale-watch.sh
StateDirectory=headscale-watch
StateDirectoryMode=0750
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
Nice=10

headscale-watch.timer

1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Surveillance Headscale toutes les 15 minutes

[Timer]
OnCalendar=*:0/15
RandomizedDelaySec=60
Persistent=true

[Install]
WantedBy=timers.target

En cas d’alerte, un message est envoyé via notify

Cet article est sous licence CC BY 4.0 par l'auteur.