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
22si 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_urldoit être exactement l’URL HTTPS publique.listen_addr: 127.0.0.1:8080évite d’exposer directement l’API Headscale.derp.server.enabled: trueactive le DERP intégré.ipv4etipv6déclarent les IP publiques réellement annoncées aux clients.stun_listen_addrrend STUN disponible sur UDP 3478.- Les listes
urlsetpathsvides, avecauto_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
headscalelancé depuis un autre poste (variablesHEADSCALE_CLI_ADDRESSetHEADSCALE_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
expireetdeletesur l’ancienne. Les options (--prefix,--id) varient selon la version,headscale apikeys --helpdonne 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
cwwkseulement 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 :
cwwkrecevra :tag:server,tag:nfs-server.e6230wrecevra :tag:admin,tag:nfs-client.pc1recevra :tag:admin,tag:nfs-client.proxmox,vm105,skrime,yannig,xoyaz,yannir,yiakreçoivent :tag:server.- Android reçoit :
tag:mobileseulement. sxbne doit pas recevoir detag:nfs-*; il peut rester non inscrit, ou être classétag:control-planesi 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 groupegroup:mesh-adminrassemble 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 portanttag:adminpeuvent 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 portanttag:admin, et non à un nombre fixe de machines.
Pour le partage de fichiers, seuls les nœudstag:nfs-clientpeuvent joindre le nœudtag: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 tagstag:mobileettag:control-planesont 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.
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.
pc1: premier poste d’administration et premier client NFS.cwwk: serveur NFS.e6230w: second client NFS.skrime: VPS IPv6-only.proxmoxetvm105.- Les VPS publics restants :
yannig,xoyaz,yannir,yiak. - 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
tinyenproxtin - Ajouter le tag
nfs-clientaux tags existantstag: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/10etfd7a:115c:a1e0::/48) au lieu de lister les machines une par une. Le contrôle fin reste fait par les ACL Headscale et le tagnfs-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-clientatteignent 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 surcwwk.
/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
, 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
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_checkSQLite, âge de la dernière sauvegarde (siBACKUP_GLOBest défini), NTP, mises à jour de sécurité, ports inattendus (siEXPECTED_PORTSest 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=--ipv6ou laissez-le vide, car votre--ipv4ne passerait pas. - Votre exemple contient un backtick parasite après « Contenu du message » : dans bash, il ouvrirait une substitution de commande.
NTFY_EMAIL_LEVEL=CRITn’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

