PC1 - Audit Headscale Tailscale

PC1 - Audit Headscale Tailscale

Notes

ℹ️ Etat des lieux d'un serveur Headscale et de ses clients Tailscale

Script hs-audit.sh

Les clients sont interrogés en parallèle, puis affichés dans l’ordre. Le snippet client est en POSIX sh, donc il tourne sur Alpine sans bash ni jq. Un client hors ligne selon Headscale est signalé sans tentative SSH, et le poste local est audité sans passer par SSH.

💡 Le script $HOME/.local/bin/hs-audit.sh
Le terminal affiche toujours la sortie complète. Seul le fichier HTML est masqué, et il est créé en mode 600.

Création script $HOME/.local/bin/hs-audit.sh

Utilisation

  • ./hs-audit.sh # serveur + clients (lignes du CSV avec Opt=mesh) + poste local
  • ./hs-audit.sh server # serveur seulement
  • ./hs-audit.sh clients [alias…] # clients seulement (alias explicites, sinon tous les clients avec Opt=mesh dans le CSV)

Options :

  • --html[=fichier] génère un rapport HTML (défaut : hs-audit-AAAA-MM-JJ.html) ;
données sensibles vérifiées puis masquée

, refus si un résidu est détecté

  • --brut avec --html : aucun masquage (rapport à ne pas partager)
  • --force avec --html : génère le rapport malgré un résidu détecté

Ce qui est masqué

  • Le contenu des sections « Clés de pré-authentification » et « Clés API » est remplacé par « (contenu masqué) ».
  • Chaque hôte ou domaine du CSV, ainsi que celui du server_url, est remplacé par [alias] ou [headscale].
  • Les jetons longs contenant des chiffres (clés de nœud, hachages) deviennent [masqué], et les adresses e-mail [email].
  • Les IPv4 publiques sont réduites à 203.x.x.x, et les IPv6 publiques à [ipv6].
  • Les IP privées et celles du tailnet (100.64/10, fd7a:…) sont gardées. Avec REDACT_PRIVATE=1, elles sont masquées aussi.

Le contrôle : le masquage terminé, un second contrôle cherche les restes (hôtes du CSV, clés, e-mails, IPv4 publiques). S’il en trouve, il affiche seulement les numéros de ligne, pas le contenu, et le fichier n’est pas écrit (code retour 2). Il ne peut pas reconnaître ce qu’il ne connaît pas : les noms de nœuds, d’utilisateurs Headscale, et un domaine absent du CSV restent visibles. Relisez donc la page avant de la partager.

Mise en page : une seule page HTML avec thème clair ou sombre automatique, une section par partie de l’audit, les lignes !! en rouge et un compteur d’avertissements en en-tête.

Le script hs-audit.sh , après création le rendre exécutable chmod +x hs-audit.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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
#!/usr/bin/env bash
# hs-audit.sh — état des lieux d'un serveur Headscale et de ses clients Tailscale
# À lancer depuis un poste Arch Linux (client Tailscale).
# Les connexions SSH sont résolues depuis le CSV de sshm :
#   Alias;User;IPouDomain;Port_SSH;SSH_Key;Local;Distant;Ping;Opt;IP4_6
# et lancées comme sshm : ssh user@hôte -p port -i clé
# Ping=Y : le script teste d'abord que l'hôte répond (ping -4/-6 selon IP4_6) ;
# Opt contient le drapeau « mesh » (MESH_FLAG) pour les machines du réseau maillé Tailscale ;
# Local/Distant (sshfs) ne sont pas utilisés.
#
# Usage :
#   ./hs-audit.sh                      # serveur + clients (lignes du CSV avec Opt=mesh) + poste local
#   ./hs-audit.sh server               # serveur seulement
#   ./hs-audit.sh clients [alias...]   # clients seulement (alias explicites, sinon Opt=mesh)
#
# Options :
#   --html[=fichier]  génère aussi un rapport HTML (défaut : hs-audit-AAAA-MM-JJ.html) ;
#                     données sensibles masquées puis vérifiées, refus si un résidu est détecté
#   --brut            avec --html : aucun masquage (rapport à ne pas partager)
#   --force           avec --html : génère le rapport malgré un résidu détecté
#
# Variables (surchargeables par l'environnement) :
#   HS_ALIAS   alias CSV du VPS Headscale        (défaut : headscale)
#   HS_URL     server_url public de Headscale    (défaut : https://headscale.example.org)
#   SSH_CSV    chemin du CSV (défaut : détecté dans le script sshm)
#   MESH_FLAG  mot cherché dans la colonne Opt  (défaut : mesh ; plusieurs mots possibles
#              dans Opt, séparés par espace, virgule ou |)
#   LOCAL      1 (défaut) : auditer aussi le poste local, sans SSH ; 0 pour l'ignorer
#   EXCLUDE    alias à ignorer, séparés par des espaces
#   REDACT_PRIVATE  1 : masque aussi les IP privées et tailnet dans le HTML (défaut : 0)

set -u

HS_ALIAS="${HS_ALIAS:-headscale}"
HS_URL="${HS_URL:-https://headscale.example.org}"
SSH_CSV="${SSH_CSV:-}"
EXCLUDE="${EXCLUDE:-}"
MESH_FLAG="${MESH_FLAG:-mesh}"
LOCAL="${LOCAL:-1}"
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new)

HTML_OUT=""; RAW=0; FORCE=0; _args=()
for _o in "$@"; do
  case "$_o" in
    --html)    HTML_OUT="hs-audit-$(date +%F).html" ;;
    --html=*)  HTML_OUT="${_o#--html=}" ;;
    --brut)    RAW=1 ;;
    --force)   FORCE=1 ;;
    -h|--help) sed -n '2,32p' "$0"; exit 0 ;;
    *)         _args+=("$_o") ;;
  esac
done
set -- ${_args[@]+"${_args[@]}"}
MODE="${1:-all}"; shift 2>/dev/null || true

titre() { printf '\n\033[1;34m=== %s ===\033[0m\n' "$*"; }

# Chemin du CSV : variable SSH_CSV, sinon déduit du script sshm
if [ -z "$SSH_CSV" ] && command -v sshm >/dev/null 2>&1; then
  SSH_CSV=$(grep -oE '[^ "'\''=]+\.csv' "$(command -v sshm)" 2>/dev/null | head -1)
  SSH_CSV="${SSH_CSV/#\$HOME/$HOME}"; SSH_CSV="${SSH_CSV/#\~/$HOME}"
fi

# Lit la ligne de l'alias $1 -> C_USER C_HOST C_PORT C_KEY C_IPV (retour 1 si absent)
csv_get() {
  local a u h p k l d pi o v
  while IFS=';' read -r a u h p k l d pi o v _; do
    a=${a//$'\r'/}
    if [ "${a,,}" = "${1,,}" ]; then
      C_USER=$u; C_HOST=$h; C_PORT=${p:-22}; C_KEY=$k; C_PING=$pi; C_IPV=${v//$'\r'/}
      return 0
    fi
  done < <(tail -n +2 "$SSH_CSV")
  return 1
}

# Construit SSH_CMD à partir des variables C_* (même forme que sshm)
ssh_cmd() {
  local key="$C_KEY"
  key="${key/#\~/$HOME}"; key="${key/#\$HOME/$HOME}"
  [ -n "$key" ] && [[ "$key" != */* ]] && key="$HOME/.ssh/$key"
  SSH_CMD=(ssh "${SSH_OPTS[@]}" -p "$C_PORT")
  [ -n "$key" ] && SSH_CMD+=(-i "$key")
  SSH_CMD+=("$C_USER@$C_HOST")
}

# Si Ping=Y, vérifie que l'hôte répond au ping (-4/-6 selon IP4_6). Retour 0 si OK ou non demandé.
ping_check() {
  [ "${C_PING^^}" = "Y" ] || return 0
  local f=()
  case "$C_IPV" in 4) f=(-4);; 6) f=(-6);; esac
  ping "${f[@]}" -c1 -W3 "$C_HOST" >/dev/null 2>&1
}

# ---------------------------------------------------------------------------
# Snippet exécuté sur le serveur Headscale (Debian, bash)
# ---------------------------------------------------------------------------
read -r -d '' SERVER_SNIPPET <<'EOF'
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
sec() { printf '\n--- %s ---\n' "$*"; }
HS="$SUDO headscale"

sec "Système"
. /etc/os-release; echo "$PRETTY_NAME — noyau $(uname -r)"
uptime -p; echo "Charge : $(cut -d' ' -f1-3 /proc/loadavg)"
free -h | sed -n '1,2p'
df -h / /var/lib/headscale 2>/dev/null | awk '!seen[$0]++'

sec "Service Headscale"
$HS version 2>&1 | head -1
echo "actif=$(systemctl is-active headscale) enabled=$(systemctl is-enabled headscale)"
systemctl show headscale -p ActiveEnterTimestamp --value 2>/dev/null | sed 's/^/démarré : /'
echo "Écoute :"; $SUDO ss -tulpn 2>/dev/null | grep -i headscale

sec "Configuration (clés principales)"
CFG=/etc/headscale/config.yaml
$SUDO grep -nE '^\s*(server_url|listen_addr|metrics_listen_addr|grpc_listen_addr|base_domain|magic_dns|ephemeral_node_inactivity_timeout|acme_email|tls_cert_path|disable_check_updates):' "$CFG" 2>/dev/null
$SUDO grep -nE -A3 '^\s*prefixes:' "$CFG" 2>/dev/null | head -6
$SUDO grep -nE -A4 '^\s*derp:' "$CFG" 2>/dev/null | grep -E 'enabled|urls|paths' 

sec "Utilisateurs"
$HS users list 2>&1

sec "Nœuds"
$HS nodes list 2>&1

sec "Routes annoncées / approuvées"
($HS nodes list-routes 2>/dev/null || $HS routes list 2>&1) | head -40

sec "Clés de pré-authentification"
if $HS preauthkeys list >/dev/null 2>&1; then
  $HS preauthkeys list 2>&1
else
  for u in $($HS users list -o json 2>/dev/null | grep -oE '"name": *"[^"]+"' | cut -d'"' -f4); do
    echo "[utilisateur $u]"; $HS preauthkeys list -u "$u" 2>&1
  done
fi

sec "Clés API"
$HS apikeys list 2>&1

sec "Base de données"
$SUDO ls -lh /var/lib/headscale/ 2>/dev/null

sec "Certificat TLS (server_url)"
H=$(echo "$HS_URL_VAR" | sed -E 's#^https?://##; s#[:/].*##')
echo | openssl s_client -connect "$H:443" -servername "$H" 2>/dev/null | openssl x509 -noout -subject -enddate 2>/dev/null \
  || echo "impossible de lire le certificat de $H"
echo "health : $(curl -fsS -m 5 "$HS_URL_VAR/health" 2>&1)"

sec "Journaux (avertissements, 24 h)"
journalctl -u headscale -p warning --since "24 hours ago" --no-pager 2>/dev/null | tail -15

sec "Mises à jour en attente"
apt list --upgradable 2>/dev/null | grep -v '^Listing' | head -15
EOF

# ---------------------------------------------------------------------------
# Snippet exécuté sur chaque client (POSIX sh : Alpine/ash, Debian, Arch)
# $1 = HS_URL
# ---------------------------------------------------------------------------
read -r -d '' CLIENT_SNIPPET <<'EOF'
HS_URL="${1:-}"
T=tailscale
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then T="sudo -n tailscale"; fi

printf 'Hôte      : %s\n' "$(hostname)"
if [ -r /etc/os-release ]; then . /etc/os-release; printf 'OS        : %s\n' "${PRETTY_NAME:-?}"; fi
printf 'Noyau     : %s\n' "$(uname -r)"
printf 'Uptime    : %s\n' "$(uptime | sed 's/^ *//')"

if ! command -v tailscale >/dev/null 2>&1; then echo "!! tailscale non installé"; exit 0; fi
printf 'Version   : %s\n' "$(tailscale version 2>/dev/null | head -1)"

# Service (systemd ou OpenRC)
if [ -d /run/systemd/system ]; then
  printf 'Service   : tailscaled actif=%s enabled=%s\n' \
    "$(systemctl is-active tailscaled 2>&1)" "$(systemctl is-enabled tailscaled 2>&1)"
elif command -v rc-service >/dev/null 2>&1; then
  printf 'Service   : %s | runlevel: %s\n' \
    "$(rc-service tailscale status 2>&1 | tr '\n' ' ')" \
    "$(rc-update show 2>/dev/null | grep -w tailscale | tr -s ' ' | tr '\n' ' ')"
fi

J=$($T status --json 2>/dev/null)
if [ -z "$J" ]; then echo "!! tailscale status injoignable (daemon arrêté ?)"; exit 0; fi

printf 'État      : %s\n' "$(echo "$J" | grep -m1 '"BackendState"' | sed 's/.*: *//; s/[",]//g')"
printf 'IP        : %s\n' "$($T ip 2>/dev/null | tr '\n' ' ')"
printf 'Expiration clé : %s\n' "$(echo "$J" | grep -m1 '"KeyExpiry"' | sed 's/.*: *//; s/[",]//g')"

echo "Préférences :"
$T debug prefs 2>/dev/null | grep -E '"(ControlURL|Hostname|RouteAll|CorpDNS|ExitNodeIP|ExitNodeID|RunSSH|ShieldsUp|AdvertiseRoutes|AdvertiseTags|OperatorUser|AutoUpdate)"' | sed 's/^ */  /'
CU=$($T debug prefs 2>/dev/null | grep -m1 '"ControlURL"' | sed 's/.*: *"//; s/".*//')
if [ -n "$HS_URL" ] && [ -n "$CU" ] && [ "${CU%/}" != "${HS_URL%/}" ]; then
  echo "!! ControlURL ($CU) différent du serveur attendu ($HS_URL)"
fi

echo "Santé :"
$T status 2>&1 | grep -E '^#' | sed 's/^/  /'
[ "$(echo "$J" | grep -c '"Health": *\[\]')" -eq 1 ] && echo "  (aucun avertissement)"

# Joignabilité du serveur Headscale depuis le client
if [ -n "$HS_URL" ]; then
  if command -v curl >/dev/null 2>&1; then
    printf 'Serveur   : /health -> %s\n' "$(curl -fsS -m 5 -o /dev/null -w '%{http_code} en %{time_total}s' "$HS_URL/health" 2>&1)"
  elif command -v wget >/dev/null 2>&1; then
    wget -q -T 5 -O /dev/null "$HS_URL/health" && echo "Serveur   : /health OK" || echo "Serveur   : /health KO"
  fi
fi

# Mises à jour en attente
if command -v apt-get >/dev/null 2>&1; then
  printf 'MAJ       : %s paquet(s) (apt, selon cache local)\n' "$(apt list --upgradable 2>/dev/null | grep -vc '^Listing')"
elif command -v pacman >/dev/null 2>&1; then
  printf 'MAJ       : %s paquet(s) (pacman, selon base locale)\n' "$(pacman -Qu 2>/dev/null | wc -l)"
elif command -v apk >/dev/null 2>&1; then
  printf 'MAJ       : %s paquet(s) (apk, selon index local)\n' "$(apk version -l '<' 2>/dev/null | tail -n +2 | wc -l)"
fi
EOF


# ---------------------------------------------------------------------------
audit_server() {
  titre "SERVEUR HEADSCALE — alias $HS_ALIAS"
  csv_get "$HS_ALIAS" || { echo "!! alias '$HS_ALIAS' absent de $SSH_CSV (définir HS_ALIAS)"; return 1; }
  ssh_cmd
  ping_check || { echo "!! $HS_ALIAS ($C_HOST) ne répond pas au ping (Ping=Y dans le CSV) — SSH non tenté"; return 1; }
  { echo "HS_URL_VAR='$HS_URL'"; printf '%s\n' "$SERVER_SNIPPET"; } \
    | "${SSH_CMD[@]}" 'bash -s' 2>&1 \
    || echo "!! connexion SSH impossible vers $HS_ALIAS ($C_HOST:$C_PORT)"
}

# Alias du CSV dont la colonne Opt contient le drapeau MESH_FLAG
mesh_aliases() {
  local a u h p k l d pi o v
  while IFS=';' read -r a u h p k l d pi o v _; do
    a=${a//$'\r'/}; [ -n "$a" ] || continue
    o=${o,,}; o=${o//[ |]/,}
    case ",$o," in *",${MESH_FLAG,,},"*) echo "$a";; esac
  done < <(tail -n +2 "$SSH_CSV")
}

audit_client() {  # $1 = alias ; $2 = 1 si poste local
  local a="$1" self="$2"
  if [ "$self" = "1" ]; then
    sh -s -- "$HS_URL" <<<"$CLIENT_SNIPPET" 2>&1; return
  fi
  csv_get "$a" || { echo "!! alias absent du CSV"; return; }
  ssh_cmd
  ping_check || { echo "!! $a ($C_HOST) ne répond pas au ping (Ping=Y dans le CSV) — SSH non tenté"; return; }
  "${SSH_CMD[@]}" "sh -s -- '$HS_URL'" <<<"$CLIENT_SNIPPET" 2>&1 \
    || echo "!! SSH impossible vers $a ($C_HOST:$C_PORT)"
}

audit_clients() {
  titre "CLIENTS TAILSCALE (CSV, Opt contient « $MESH_FLAG »)"
  local tmp; tmp=$(mktemp -d)
  local -a list=()
  local i=0 a n

  if [ "$#" -gt 0 ]; then list=("$@"); else mapfile -t list < <(mesh_aliases); fi

  if [ "$LOCAL" = "1" ] && [ "$#" -eq 0 ]; then
    echo "poste local" >"$tmp/0.name"
    audit_client local 1 >"$tmp/0" 2>&1 &
  fi
  for a in "${list[@]}"; do
    case " $EXCLUDE " in *" $a "*) continue;; esac
    i=$((i+1)); echo "$a" >"$tmp/$i.name"
    audit_client "$a" 0 >"$tmp/$i" 2>&1 &
  done
  wait
  [ "$i" -gt 0 ] || echo "Aucune ligne du CSV avec Opt=$MESH_FLAG (voir MESH_FLAG)"

  for n in $(seq 0 "$i"); do
    [ -f "$tmp/$n" ] || continue
    printf '\n\033[1;32m--- %s ---\033[0m\n' "$(cat "$tmp/$n.name")"
    cat "$tmp/$n"
  done
  reconcile "$tmp"
  rm -rf "$tmp"
}

# Compare les nœuds du tailnet (vus depuis le poste local) aux machines auditées
reconcile() {
  command -v tailscale >/dev/null && command -v jq >/dev/null || return 0
  local known=" " f h
  for f in "$1"/[0-9]*; do
    case "$f" in
      *.name) known+="$(tr 'A-Z' 'a-z' <"$f") " ;;
      *)      known+="$(sed -n 's/^Hôte *: *//p' "$f" | head -1 | tr 'A-Z' 'a-z') " ;;
    esac
  done
  local -a inconnus=()
  while read -r h; do
    case "$known" in *" ${h,,} "*) ;; *) inconnus+=("$h");; esac
  done < <(tailscale status --json 2>/dev/null | jq -r '(.Self, (.Peer // {} | .[])) | .HostName')
  if [ "${#inconnus[@]}" -gt 0 ]; then
    printf '\n\033[1;33mNœuds du tailnet non audités (absents du CSV ou sans Opt=%s) : %s\033[0m\n' "$MESH_FLAG" "${inconnus[*]}"
  fi
}

# ---------------------------------------------------------------------------
# Rapport HTML : masquage des données sensibles, vérification, mise en page
# ---------------------------------------------------------------------------
read -r -d '' REDACT_AWK <<'AWKEOF'
function subre(s, re, repl,   out) {
  out = ""
  while (match(s, re)) { out = out substr(s, 1, RSTART-1) repl; s = substr(s, RSTART+RLENGTH) }
  return out s
}
function fixrep(s, h, r,   out, p) {
  out = ""
  while ((p = index(s, h)) > 0) { out = out substr(s, 1, p-1) r; s = substr(s, p+length(h)) }
  return out s
}
function is_priv(a) {
  return (a[1]==0 || a[1]==10 || a[1]==127 || (a[1]==192 && a[2]==168) || (a[1]==172 && a[2]>=16 && a[2]<=31) || (a[1]==100 && a[2]>=64 && a[2]<=127) || (a[1]==169 && a[2]==254))
}
function mask4(s,   out, ip, pre, a) {
  out = ""
  while (match(s, /[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+/)) {
    ip = substr(s, RSTART, RLENGTH); pre = substr(s, 1, RSTART-1); s = substr(s, RSTART+RLENGTH)
    split(ip, a, ".")
    if (a[1]>255 || a[2]>255 || a[3]>255 || a[4]>255) out = out pre ip
    else if (is_priv(a) && !priv) out = out pre ip
    else out = out pre a[1] ".x.x.x"
  }
  return out s
}
function keep6(ip,   l) {
  l = tolower(ip)
  if (l == "::" || l == "::1" || l ~ /^(fd|fc|fe80)/) return (priv ? "[ipv6 privée]" : ip)
  return "[ipv6]"
}
function mask6(s,   out, ip, pre) {
  out = ""
  while (match(s, /[0-9a-fA-F:]*::[0-9a-fA-F:]*|[0-9a-fA-F]+:[0-9a-fA-F]+:[0-9a-fA-F]+:[0-9a-fA-F]+:[0-9a-fA-F]+(:[0-9a-fA-F]+)*/)) {
    ip = substr(s, RSTART, RLENGTH); pre = substr(s, 1, RSTART-1); s = substr(s, RSTART+RLENGTH)
    out = out pre keep6(ip)
  }
  return out s
}
function tokmask(s,   out, t, pre) {
  out = ""
  while (match(s, /[A-Za-z0-9_+]+/)) {
    t = substr(s, RSTART, RLENGTH); pre = substr(s, 1, RSTART-1); s = substr(s, RSTART+RLENGTH)
    if (length(t) >= 24 && t ~ /[0-9]/) t = "[masqué]"
    out = out pre t
  }
  return out s
}
BEGIN {
  while ((getline l < hosts) > 0) {
    split(l, f, "\t")
    if (length(f[1]) >= 4) { n++; H[n] = f[1]; R[n] = f[2] }
  }
}
/^--- .*authentification ---$/ || /^--- Cl.*s API ---$/ { print; print "(contenu masqué)"; skip = 1; next }
skip && (/^--- / || /^=== /) { skip = 0 }
skip { next }
{
  s = $0
  s = subre(s, "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]*[A-Za-z]", "[email]")
  for (i = 1; i <= n; i++) s = fixrep(s, H[i], R[i])
  s = tokmask(s)
  s = mask6(s)
  s = mask4(s)
  print s
}
AWKEOF

read -r -d '' HTML_AWK <<'AWKEOF'
function esc(s) { gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s); return s }
function closepre() { if (inpre) { print "</pre>"; inpre = 0 } }
/^=== .* ===$/ {
  closepre(); if (sec) print "</section>"
  t = $0; sub(/^=== /, "", t); sub(/ ===$/, "", t)
  print "<section>\n<h2>" esc(t) "</h2>"; sec = 1; next
}
/^--- .* ---$/ { closepre(); t = $0; sub(/^--- /, "", t); sub(/ ---$/, "", t); print "<h3>" esc(t) "</h3>"; next }
{
  if (!inpre && $0 ~ /^[ \t]*$/) next
  if (!inpre) { print "<pre>"; inpre = 1 }
  if ($0 ~ /^!!/) print "<span class=\"warn\">" esc($0) "</span>"
  else if ($0 ~ /non audités/) print "<span class=\"note\">" esc($0) "</span>"
  else print esc($0)
}
END { closepre(); if (sec) print "</section>" }
AWKEOF

# Table « hôte<TAB>[alias] » issue du CSV (+ hôte du server_url), triée par longueur décroissante
build_hosts() {
  {
    tail -n +2 "$SSH_CSV" | tr -d '\r' | awk -F';' 'length($3) >= 4 && $1 != "" { print $3 "\t[" $1 "]" }'
    local h="${HS_URL#*://}"; h="${h%%[:/]*}"
    [ "${#h}" -ge 4 ] && printf '%s\t[headscale]\n' "$h"
  } | awk -F'\t' '!s[$1]++ { print length($1) "\t" $0 }' | sort -rn | cut -f2-
}

# Contrôle indépendant du masquage ; affiche seulement des numéros de ligne, jamais le contenu
verify_clean() {  # $1 = texte masqué, $2 = table des hôtes ; retour 1 si résidu
  local f="$1" hosts="$2" bad=0 h pat
  while IFS=$'\t' read -r h _; do
    [ "${#h}" -ge 4 ] || continue
    if grep -qF -- "$h" "$f"; then echo "  résiduel : un hôte du CSV apparaît encore"; bad=1; fi
  done <"$hosts"
  for pat in 'tskey-' 'hskey-' 'nodekey:[0-9a-fA-F]' 'mkey:[0-9a-fA-F]' 'PRIVATE KEY' '[0-9a-fA-F]{32,}' '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}'; do
    if grep -qE -- "$pat" "$f"; then
      echo "  résiduel : motif sensible (lignes $(grep -nE -- "$pat" "$f" | cut -d: -f1 | head -5 | tr '\n' ' '))"; bad=1
    fi
  done
  if grep -oE '[0-9]+([.][0-9]+){3}' "$f" | awk -F. -v priv="${REDACT_PRIVATE:-0}" '
      ($1<=255 && $2<=255 && $3<=255 && $4<=255) &&
      (priv=="1" || !($1==0 || $1==10 || $1==127 || ($1==192&&$2==168) || ($1==172&&$2>=16&&$2<=31) || ($1==100&&$2>=64&&$2<=127) || ($1==169&&$2==254))) { r=1 }
      END { exit !r }'; then
    echo "  résiduel : adresse IPv4 à démasquer"; bad=1
  fi
  return "$bad"
}

html_page() {  # $1 texte final, $2 fichier HTML, $3 « masqué » ou « brut »
  local nwarn badge
  nwarn=$(grep -c '^!!' "$1")
  if [ "$3" = "brut" ]; then badge='<span class="badge bad">brut — ne pas partager</span>'
  else badge='<span class="badge ok">données sensibles masquées</span>'; fi
  {
    printf '<!doctype html>\n<html lang="fr"><head><meta charset="utf-8">\n'
    printf '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
    printf '<title>État des lieux Headscale — %s</title>\n<style>\n' "$(date +%F)"
    cat <<'CSSEOF'
:root{color-scheme:light dark;--bg:#fff;--fg:#1c1e21;--mut:#6b7280;--card:#f5f6f8;--bd:#d9dde3;--warn:#b42318;--ok:#067647;--note:#92400e}
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--fg:#e6e8eb;--mut:#9aa3af;--card:#171a21;--bd:#2a2f3a;--warn:#f97066;--ok:#47cd89;--note:#fbbf24}}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
main{max-width:1000px;margin:0 auto;padding:1.5rem 1rem 3rem}
h1{font-size:1.5rem;margin:.2rem 0}h2{font-size:1.2rem;margin:2rem 0 .5rem;border-bottom:1px solid var(--bd);padding-bottom:.3rem}
h3{font-size:1rem;margin:1.2rem 0 .3rem}
.meta{color:var(--mut);margin:.2rem 0 1rem}
.badge{display:inline-block;padding:.05rem .55rem;border-radius:999px;border:1px solid currentColor;font-size:.85rem}
.badge.ok{color:var(--ok)}.badge.bad{color:var(--warn)}
pre{background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:.7rem .9rem;overflow-x:auto;margin:.3rem 0;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
.warn{color:var(--warn);font-weight:600}.note{color:var(--note)}
CSSEOF
    printf '</style></head><body><main>\n<h1>État des lieux Headscale / Tailscale</h1>\n'
    printf '<p class="meta">Généré le %s · avertissements : %s · %s</p>\n' "$(date '+%F %H:%M')" "$nwarn" "$badge"
    awk "$HTML_AWK" "$1"
    printf '</main></body></html>\n'
  } >"$2"
}

# Permet de sourcer le fichier pour tester les fonctions
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0

[ -r "$SSH_CSV" ] || { echo "CSV introuvable : '${SSH_CSV}' (définir SSH_CSV=/chemin/fichier.csv)"; exit 1; }

run_audit() {
  case "$MODE" in
    server)  audit_server ;;
    clients) audit_clients "$@" ;;
    all)     audit_server; audit_clients ;;
    *) echo "Usage : $0 [--html[=fichier]] [--brut] [--force] [server|clients [alias...]]"; return 1 ;;
  esac
}

if [ -z "$HTML_OUT" ]; then run_audit "$@"; exit $?; fi

# --- Mode rapport HTML ---
work=$(mktemp -d); trap 'rm -rf "$work"' EXIT

run_audit "$@" 2>&1 | tee "$work/raw"
sed 's/\x1b\[[0-9;]*m//g' "$work/raw" >"$work/plain"

if [ "$RAW" = 1 ]; then
  cp "$work/plain" "$work/final"; label="brut"
else
  build_hosts >"$work/hosts"
  printf '%s\n' "$REDACT_AWK" >"$work/redact.awk"
  awk -v hosts="$work/hosts" -v priv="${REDACT_PRIVATE:-0}" -f "$work/redact.awk" "$work/plain" >"$work/final"
  echo; echo "Vérification du masquage…"
  if verify_clean "$work/final" "$work/hosts"; then
    echo "  OK : aucune donnée sensible détectée"
  elif [ "$FORCE" = 1 ]; then
    echo "  (--force : rapport généré malgré tout)"
  else
    echo "HTML non généré : données sensibles résiduelles (--force pour passer outre)."; exit 2
  fi
  label="masqué"
fi

( umask 077; html_page "$work/final" "$work/out.html" "$label" )
mv "$work/out.html" "$HTML_OUT"
echo "Rapport HTML ($label) : $HTML_OUT"

Automatisation

minuteur systemd utilisateur qui lance l’audit 5 minutes après le démarrage de votre session. J’ai testé le script hs-audit-boot.sh avec un faux ssh, en réussite comme en échec, pas les unités systemd elles-mêmes.

Installation sur Arch desktop

1
2
3
4
5
6
7
8
install -Dm755 hs-audit.sh hs-audit-boot.sh -t ~/.local/bin/
install -Dm644 hs-audit-boot.service hs-audit-boot.timer -t ~/.config/systemd/user/
install -Dm600 hs-audit.env.example ~/.config/hs-audit.env     # puis renseigner HS_ALIAS et HS_URL
systemctl --user daemon-reload
systemctl --user start hs-audit-boot.service                   # test immédiat
journalctl --user -u hs-audit-boot -n 20
systemctl --user enable --now hs-audit-boot.timer
systemctl --user list-timers hs-audit-boot.timer               # vérifier la prochaine exécution

Ce que fait le lancement

  • Attente de Tailscale : le script patiente jusqu’à 120 s, par pas de 5 s, que Tailscale soit connecté avant de lancer l’audit.
  • Rapport HTML : il est écrit dans ~/.local/share/hs-audit/hs-audit-AAAA-MM-JJ_HHMM.html, avec le lien dernier.html vers le plus récent. Le masquage de --html s’applique, et les rapports de plus de 30 jours sont supprimés.
  • Notification de bureau : notify-send signale le nombre d’avertissements, ou l’échec de génération. Avec HS_AUDIT_NOTIFY_OK=1, il notifie aussi quand tout est OK.
  • Pas de fuite dans le journal : la sortie brute de l’audit reste dans un fichier temporaire en mémoire et est supprimée à la fin. Le journal ne reçoit que le chemin du rapport, le nombre d’avertissements et les lignes d’erreur (!!).

Points d’attention

  • Le minuteur démarre avec la session utilisateur, pas au tout premier démarrage de la machine. Avec une connexion automatique, c’est quasi équivalent. Pour un lancement avant toute connexion, il faudrait un service système avec User=, mais l’agent SSH et les notifications de bureau n’y seraient pas disponibles.
  • Le script utilise SSH en BatchMode. Si vos clés ont une phrase secrète, l’audit n’aboutira que si elles sont chargées dans l’agent SSH au moment du lancement, sinon les machines apparaîtront en « SSH impossible ».
  • Le service lance désormais bash hs-audit.sh, donc le bit exécutable n’est pas nécessaire (mon environnement ne le conservait pas).

hs-audit-boot.sh envoie maintenant une notification ntfy en plus de la notification de bureau.

Configuration : ajoutez les mêmes variables que pour headscale-watch dans ~/.config/hs-audit.env, qui doit rester en mode 600.

1
2
3
4
5
NTFY_URL=https://ntfy.example.org/audit
NTFY_TOKEN=tk_xxxx
NTFY_EMAIL=moi@example.org
NTFY_EMAIL_LEVEL=ALL        # ou CRIT : e-mail uniquement pour les échecs
NTFY_CURL_OPTS=--ipv4

Sans NTFY_URL, rien n’est envoyé et le script se comporte comme avant.

Ce qui est envoyé

  • Avertissements : priorité default, tag warning. Le corps liste les lignes d’alerte du rapport, chacune précédée de sa section. Elles viennent du HTML déjà masqué, donc les hôtes y figurent sous leur alias. Exemple : [nas] !! ControlURL (…) différent du serveur attendu.
  • Échec de génération : priorité high, tag rotating_light, avec un e-mail même si NTFY_EMAIL_LEVEL=CRIT. Le corps contient seulement le code de sortie et les messages génériques (CSV introuvable, résidu détecté). Je n’y mets pas les lignes !!, qui contiennent des noms d’hôtes non masqués.
  • Tout est OK : un message de priorité basse part seulement si HS_AUDIT_NOTIFY_OK=1.
  • Réseau : la notification part quand l’audit se termine. Si l’envoi échoue, une ligne est écrite dans le journal (journalctl --user -u hs-audit-boot), sans nouvelle tentative.

Si le desktop est en IPv6 seul : changez NTFY_CURL_OPTS, car --ipv4 est la valeur par défaut, comme dans votre exemple.

Les fichiers

hs-audit.env.example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ~/.config/hs-audit.env  (chmod 600) — 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

HS_ALIAS=nom_du_vps_dans_le_csv
HS_URL=https://headscale.example.org
#SSH_CSV=/chemin/vers/connexions.csv
#MESH_FLAG=mesh
#REDACT_PRIVATE=0
#HS_AUDIT_NOTIFY_OK=0
#HS_AUDIT_KEEP_DAYS=30

hs-audit-boot.service

1
2
3
4
5
6
7
8
9
[Unit]
Description=Audit Headscale/Tailscale (rapport HTML)

[Service]
Type=oneshot
EnvironmentFile=-%h/.config/hs-audit.env
ExecStart=%h/.local/bin/hs-audit-boot.sh
Nice=10
TimeoutStartSec=15min

hs-audit-boot.timer

1
2
3
4
5
6
7
8
9
[Unit]
Description=Audit Headscale quelques minutes après le démarrage de la session

[Timer]
OnStartupSec=5min
Unit=hs-audit-boot.service

[Install]
WantedBy=timers.target

hs-audit-boot.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
#!/usr/bin/env bash
# hs-audit-boot.sh — lance hs-audit.sh au démarrage de la session : rapport HTML masqué, notification, purge.
# Variables : HS_AUDIT (chemin de hs-audit.sh), HS_AUDIT_DIR (dossier des rapports), HS_AUDIT_KEEP_DAYS (30),
#             HS_AUDIT_WAIT (secondes d'attente de Tailscale, 120), HS_AUDIT_NOTIFY_OK (1 = notifier aussi si tout est OK)
set -u

AUDIT="${HS_AUDIT:-$HOME/.local/bin/hs-audit.sh}"
OUTDIR="${HS_AUDIT_DIR:-$HOME/.local/share/hs-audit}"
KEEP_DAYS="${HS_AUDIT_KEEP_DAYS:-30}"
WAIT="${HS_AUDIT_WAIT:-120}"

mkdir -p "$OUTDIR"; chmod 700 "$OUTDIR"
OUT="$OUTDIR/hs-audit-$(date +%F_%H%M).html"
# La sortie brute de l'audit (non masquée) reste en mémoire (tmpfs) et n'est jamais envoyée au journal
TMP=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/hs-audit.XXXXXX"); trap 'rm -f "$TMP"' EXIT

notify() {  # $1 urgence  $2 message
  command -v notify-send >/dev/null 2>&1 && notify-send -a hs-audit -u "$1" "Audit Headscale" "$2" 2>/dev/null
  return 0
}

# Envoi ntfy (mêmes variables que headscale-watch : NTFY_URL, NTFY_TOKEN, NTFY_EMAIL, NTFY_EMAIL_LEVEL, NTFY_CURL_OPTS)
ntfy() {  # $1 titre (ASCII)  $2 message  $3 priorité  $4 tags  $5 niveau (CRIT|WARN|OK)
  [ -n "${NTFY_URL:-}" ] || return 0
  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}" = ALL ] || [ "$5" = CRIT ]; }; then
    args+=(-H "X-Email: $NTFY_EMAIL")
  fi
  if [ -n "${NTFY_TOKEN:-}" ]; then args+=(-H "Authorization: Bearer $NTFY_TOKEN"); fi
  curl "${args[@]}" -d "$2" "$NTFY_URL" >/dev/null || echo "échec de l'envoi ntfy"
  return 0
}

HOST=$(hostname -s)

# Attend (au plus WAIT s) que Tailscale soit connecté
if command -v tailscale >/dev/null 2>&1 && [ "$WAIT" -gt 0 ]; then
  for ((i = 0; i < WAIT; i += 5)); do
    tailscale status --json 2>/dev/null | grep -q '"BackendState": *"Running"' && break
    sleep 5
  done
fi

rm -f "$OUT"
bash "$AUDIT" --html="$OUT" >"$TMP" 2>&1; rc=$?

if [ ! -s "$OUT" ]; then
  echo "Rapport non généré (code $rc)"
  grep -E '^(!!|HTML non généré|CSV introuvable|  résiduel)' "$TMP" | head -20
  notify critical "Rapport non généré (code $rc) — journalctl --user -u hs-audit-boot"
  # Pas de lignes « !! » ici : elles contiennent des noms d'hôtes non masqués
  errs=$(grep -E '^(HTML non généré|CSV introuvable|  résiduel)' "$TMP" | head -10)
  ntfy "Audit Headscale $HOST : echec" "Rapport non généré (code $rc)${errs:+$'\n'$errs}" high rotating_light CRIT
  exit 1
fi

ln -sf "$OUT" "$OUTDIR/dernier.html"
nwarn=$(grep -c 'class="warn"' "$OUT")
echo "Rapport : $OUT ($nwarn avertissement(s))"
if [ "$nwarn" -gt 0 ]; then
  notify normal "$nwarn avertissement(s) — $OUT"
  # Avertissements extraits du rapport déjà masqué, précédés du titre de leur section
  body=$(awk '/<h3>/ { h = $0; sub(/.*<h3>/, "", h); sub(/<\/h3>.*/, "", h) }
              /class="warn"/ { t = $0; gsub(/<[^>]*>/, "", t); gsub(/&lt;/, "<", t); gsub(/&gt;/, ">", t); gsub(/&amp;/, "\\&", t); print "[" h "] " t }' "$OUT" | head -c 3500)
  ntfy "Audit Headscale $HOST : $nwarn avertissement(s)" "$body" default warning WARN
elif [ "${HS_AUDIT_NOTIFY_OK:-0}" = 1 ]; then
  notify low "Tout est OK — $OUT"
  ntfy "Audit Headscale $HOST : OK" "Aucun avertissement" low white_check_mark OK
fi

find "$OUTDIR" -maxdepth 1 -name 'hs-audit-*.html' -mtime +"$KEEP_DAYS" -delete
exit 0