Configurer ssh-guard (durcissement SSH)

À qui s'adresse cette page

Administrateurs qui déploient ou ajustent la couche ssh-guard de gitrust selon leur topologie réseau (Internet direct, derrière nginx stream, derrière HAProxy, ou réseau privé).

Avant de commencer : si vous voulez d'abord comprendre ce que fait ssh-guard et ses compromis, lisez ssh-guard : détection d'attaques SSH. La référence des variables est dans Variables d'environnement — SSH_GUARD_*.


1. Choisir le profil de déploiement

Le profil pré-configure les défauts cohérents avec votre topologie. Une variable unique pilote tout le reste : SSH_GUARD_PROFILE.

Profil Topologie Quand l'utiliser
direct gitrust écoute en :22 ou :2222 exposé Internet, sans proxy devant VPS minimal, démo publique
nginx nginx stream sur :22 avec proxy_protocol on; → gitrust sur 127.0.0.1:2222 Production avec reverse-proxy nginx (cas le plus courant)
haproxy HAProxy en frontal SSH avec send-proxy ou send-proxy-v2 Topologies multi-services
private Instance interne (VPN, réseau d'entreprise), pas d'attaque externe attendue Intranet, lab
custom Aucun preset, vous fixez chaque variable individuellement Cas exotiques

Le défaut au démarrage si SSH_GUARD_PROFILE n'est pas défini est custom, qui équivaut au profil direct côté détecteurs mais avec PROXY protocol désactivé.


2. Recette : profil direct (Internet direct)

Cas typique : VPS, pas de reverse-proxy SSH devant gitrust. Le port :2222 est ouvert sur Internet et peer_addr() est l'IP réelle du client.

2.1 .env minimal

Copiez template/env/ssh-guard-direct.env dans votre /opt/gitrust/.env ou ajoutez :

SSH_GUARD_ENABLED=true
SSH_GUARD_DRY_RUN=false
SSH_GUARD_PROFILE=direct

# Stockage hybride (RAM chaude + write-through PostgreSQL)
SSH_GUARD_STORE_BACKEND=hybrid

# Logs JSON dans journald + fichier dédié pour fail2ban
SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=both
SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json

Les seuils par défaut s'appliquent : brute-force 5/5min, énumération 10/5min, scan de clés 10/5min, flood 10/s burst 20, ban 1 h.

2.2 Redémarrage et vérification

sudo systemctl restart gitrust

# Le module annonce sa configuration au démarrage
sudo journalctl -u gitrust --since "1 min ago" | grep "SSH guard runtime assembly"
# Attendu : profile=direct enabled=true dry_run=false store=Hybrid

# Logrotate sur le fichier dédié
sudo tee /etc/logrotate.d/gitrust-ssh-guard <<'EOF'
/var/log/gitrust-ssh-guard.json {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
}
EOF

3. Recette : profil nginx (derrière nginx stream)

Cas typique : nginx termine TLS sur :443, fait du SSH stream sur :22127.0.0.1:2222 avec PROXY protocol v2.

3.1 nginx (rappel — voir aussi Durcir avec Fail2ban)

# /etc/nginx/nginx.conf (bloc stream)
stream {
    upstream gitrust_ssh {
        server 127.0.0.1:2222;
    }

    server {
        listen 22;
        proxy_pass         gitrust_ssh;
        proxy_protocol     on;       # IMPORTANT — sinon ssh-guard rejettera
        proxy_timeout      30m;      # git push de gros pack peut être long
    }
}

3.2 .env côté gitrust

Copiez template/env/ssh-guard-nginx.env ou :

SSH_GUARD_ENABLED=true
SSH_GUARD_PROFILE=nginx

# Le profil nginx pose déjà :
#   SSH_GUARD_PROXY_PROTOCOL=v2
#   SSH_GUARD_PROXY_PROTOCOL_STRICT=true
#   SSH_GUARD_TRUSTED_PROXIES=127.0.0.1/32,::1/128
# (override individuel possible)

SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=both
SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json

Côté binding gitrust :

SSH_LISTEN_ADDR=127.0.0.1
SSH_PORT=2222

3.3 Vérification que la vraie IP est bien extraite

Faites un git ls-remote ssh://git@VOTRE_FQDN/owner/repo.git depuis une IP externe connue, puis :

sudo journalctl -u gitrust --since "1 min ago" | grep '"event":"connection_accepted"' | tail -1
# Attendu : "ip":"<VOTRE IP EXTERNE>"   et NON "ip":"127.0.0.1"

Si vous voyez 127.0.0.1, c'est que le PROXY protocol n'est pas correctement transmis : vérifiez proxy_protocol on; dans nginx.


4. Recette : profil haproxy

Le profil pose SSH_GUARD_PROXY_PROTOCOL=any (auto-détection v1 ou v2) avec strict=true, mais n'a pas de trusted_proxies par défaut (HAProxy est presque toujours sur une IP distincte). À fournir explicitement :

SSH_GUARD_ENABLED=true
SSH_GUARD_PROFILE=haproxy
SSH_GUARD_TRUSTED_PROXIES=10.0.0.5/32   # IP de l'haproxy

SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=both
SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json

Si SSH_GUARD_TRUSTED_PROXIES est vide, gitrust refuse de démarrer avec l'erreur :

SSH_GUARD_TRUSTED_PROXIES required when SSH_GUARD_PROXY_PROTOCOL is enabled

C'est volontaire : sans cette protection, n'importe qui pourrait forger un en-tête PROXY pour usurper une IP cliente.


5. Recette : profil private (réseau interne)

Détecteurs désactivés (seuils u32::MAX), événements toujours émis pour audit, store en mémoire (rapide, perdu au restart).

SSH_GUARD_ENABLED=true
SSH_GUARD_PROFILE=private

# Tous les détecteurs sont désactivés par le preset.
# Les événements connection_accepted / auth_failed / auth_succeeded
# restent émis pour audit.

SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=stderr   # journald uniquement, pas de fichier

6. Mode dry-run : observer avant de bloquer

Idéal pour valider un nouveau seuil ou laisser fail2ban faire le ban réel :

SSH_GUARD_DRY_RUN=true

Effets :

  • Les détecteurs continuent à corréler.
  • Un événement ip_banned est émis pour chaque déclenchement (signal pour fail2ban / Loki).
  • Aucun ban n'est persisté côté ssh-guard. Le BanManager reste no-op.

C'est le mode recommandé pendant les premiers jours après un changement de seuil agressif. Surveillez :

sudo journalctl -u gitrust --since "24 hours ago" --no-pager \
  | grep '"event":"ip_banned"' \
  | jq -r '.ip + " " + .reason' \
  | sort | uniq -c | sort -rn

7. Ajuster les seuils

7.1 Variables disponibles

# Brute-force : nombre d'échecs auth depuis une IP dans la fenêtre
SSH_GUARD_BRUTE_FORCE_THRESHOLD=5         # défaut 5
SSH_GUARD_BRUTE_FORCE_WINDOW_SECS=300     # défaut 300 (5 min)

# Énumération : nombre d'usernames distincts essayés depuis une IP
SSH_GUARD_USER_ENUM_THRESHOLD=10
SSH_GUARD_USER_ENUM_WINDOW_SECS=300

# Scan de clés : nombre de fingerprints distincts essayés depuis une IP
SSH_GUARD_KEY_SCAN_THRESHOLD=10
SSH_GUARD_KEY_SCAN_WINDOW_SECS=300

# Flood TCP : cap dur de connexions par seconde par IP
SSH_GUARD_CONN_FLOOD_PER_SEC=10
SSH_GUARD_CONN_FLOOD_BURST=20

# Durée d'un ban auto. 0 = permanent.
SSH_GUARD_AUTO_BAN_DURATION_SECS=3600     # défaut 3600 (1 h)

7.2 Profils suggérés

Posture Brute-force Conn flood TTL ban
Stricte (instance publique très exposée) 3 / 5 min 5/s burst 10 6 h
Standard (défaut) 5 / 5 min 10/s burst 20 1 h
Tolérante (équipe interne, partenaires CI) 10 / 5 min 20/s burst 50 30 min

Désactiver complètement un détecteur : mettre son seuil à 4294967295 (u32::MAX). Le préfixe private le fait déjà pour les quatre.


8. Allowlist d'un partenaire CI

Une IP allowlistée bypasse les détecteurs et le flood-limit, mais reste auditée. Les ACL se gèrent via la table ssh_guard_acl (admin UI ou SQL direct).

-- Allow le runner CI de l'équipe build (CIDR /32)
INSERT INTO ssh_guard_acl (id, kind, ip_cidr, reason, created_by, created_at, updated_at)
VALUES (
  gen_random_uuid(),
  'allow',
  '198.51.100.42/32',
  'Runner CI équipe build (ticket OPS-1234)',
  '<UUID admin>',
  NOW(), NOW()
);

Vérifier : depuis cette IP, déclencher 50 tentatives ratées en 1 minute, puis vérifier qu'aucun ip_banned n'a été émis :

sudo journalctl -u gitrust --since "5 min ago" --no-pager \
  | grep '"ip":"198.51.100.42"' \
  | jq -r '.event' | sort | uniq -c
# Attendu : auth_failed: 50, brute_force_detected: 0, ip_banned: 0

9. Bannir manuellement un CIDR abusif

Pour un AS qui scanne en masse, posez un ban permanent :

-- Denylist permanente d'un CIDR
INSERT INTO ssh_guard_bans (id, ip_cidr, reason, banned_at, expires_at, auto_banned)
VALUES (
  gen_random_uuid(),
  '198.51.100.0/24',
  'admin_deny_list',
  NOW(),
  NULL,         -- NULL = permanent
  false
);

expires_at = NULL rend le ban permanent. Pour un TTL fini : NOW() + INTERVAL '7 days'.


10. Troubleshooting

10.1 Tous les ip valent 127.0.0.1

Vous êtes en profil direct mais nginx fait du stream devant gitrust. Passez en nginx et activez proxy_protocol on; dans nginx.

10.2 « SSH_GUARD_TRUSTED_PROXIES required »

Vous avez activé le profil haproxy (ou SSH_GUARD_PROXY_PROTOCOL=v1|v2|any) sans déclarer de proxies de confiance. Ajoutez SSH_GUARD_TRUSTED_PROXIES=<CIDR> et redémarrez.

10.3 Faux positifs sur un développeur avec 8 clés SSH

L'agent SSH du dev présente toutes ses clés successivement. Si la 9e est la bonne, le détecteur de scan de clés pourrait s'activer (seuil 10 par défaut). Solutions, par ordre de préférence :

  1. Côté dev : forcer une clé spécifique avec IdentitiesOnly yes dans ~/.ssh/config.
  2. Côté admin : monter le seuil à 15 (SSH_GUARD_KEY_SCAN_THRESHOLD=15).

10.4 Lever un ban auto sans attendre l'expiration

UPDATE ssh_guard_bans
SET unbanned_at = NOW(), unbanned_by = '<UUID admin>'
WHERE id = '<UUID du ban>';

ssh-guard émet un ip_unbanned au prochain cycle d'effective_status.

10.5 Désactiver temporairement ssh-guard

# .env
SSH_GUARD_ENABLED=false
sudo systemctl restart gitrust

ssh-guard devient un pass-through complet : aucune détection, aucun ban, le listener délègue directement à russh. À éviter sauf urgence.


11. Pour aller plus loin