Sxb - Headscale 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
En cas d’alerte, un message est envoyé via notify
