Conception de ssh-guard¶
Ce que vous allez comprendre¶
- Identifier les défauts de sécurité SSH de gitrust avant l'introduction de ssh-guard et le périmètre exact que cette nouvelle crate couvre.
- Analyser le design async/event-driven choisi : pourquoi un listener wrapper, pourquoi des détecteurs push-based, pourquoi le store hybride.
- Évaluer les compromis (fail-open, dry-run, idempotence, priorité ACL) et savoir comment les ajuster.
Public : contributeurs au code de
gitrust-sshetgitrust-ssh-guard. Pour une recette d'exploitation, voir Configurer ssh-guard.
1. Le problème concret¶
Avant ssh-guard, le serveur SSH russh intégré à gitrust avait quatre angles morts opérationnels :
- IP cliente faussée derrière nginx stream. Le déploiement type (« nginx en frontal sur
:22,proxy_protocol on;vers127.0.0.1:2222») rendaitpeer_addr() = 127.0.0.1côté gitrust. Conséquence : tous les rate-limits par IP étaient inutilisables, les logs n'identifiaient personne, fail2ban était impossible à brancher. - Aucune détection de motifs d'attaque. La couche
russhrejette une auth invalide, mais ne corrèle rien : 1 000 tentatives en 30 secondes ressemblent à 1 000 lignes de log indépendantes. - Format de logs instable. Les messages d'erreur de
russhévoluent entre versions, ce qui rendait les filtres fail2ban fragiles (cassés à chaque mise à jour). - Pas de levier admin. Aucune ACL CIDR (allowlist d'un VPN, denylist d'un AS abusif) ne pouvait être appliquée à l'extérieur du firewall système, donc inopérante pour la majorité des admins.
ssh-guard adresse exactement ces quatre points — pas plus. Toute la couche TLS, OAuth, JWT, RBAC reste hors de son périmètre.
2. L'analogie¶
Imaginez une porte d'immeuble (le TcpListener) et derrière elle un agent d'accueil (russh) qui contrôle les badges. ssh-guard est le sas qui s'intercale entre les deux. Il fait trois choses, dans cet ordre :
- Lire la pièce d'identité correctement, qu'elle soit présentée directement ou tendue par un coursier (le proxy) — c'est la résolution d'IP réelle.
- Consulter une liste blanche/noire affichée à l'intérieur du sas — c'est l'ACL admin.
- Compter les visiteurs récents par identité et activer une alarme si quelqu'un sonne 5 fois en 5 minutes — c'est la détection.
Le sas n'inspecte pas les badges (c'est le rôle de russh). Il décide juste qui a le droit d'arriver jusqu'à l'agent d'accueil.
3. Le modèle¶
Vie d'une connexion¶
flowchart TD
A[Connexion TCP entrante] --> B[peer_addr du socket]
B --> C{proxy_config présent ?}
C -- Non, profil direct --> E[ip_réelle = peer_addr]
C -- Oui, profil proxifié --> D{peer_addr dans
trusted_proxies ?}
D -- Non --> X1[Drop : UntrustedProxy]
D -- Oui --> P[Parsing PROXY v1/v2]
P -- OK --> E2[ip_réelle = ip_cliente du header]
P -- Échec strict --> X2[Drop : ProxyHeaderInvalid/Missing]
P -- Échec souple --> E3[fallback peer_addr + warn]
E --> F[BanManager.effective_status]
E2 --> F
E3 --> F
F --> G{ACL Deny ?}
G -- Oui --> X3[Drop : Banned]
G -- Non --> H{Ban auto actif ?}
H -- Oui --> X3
H -- Non --> I{ACL Allow ?}
I -- Oui --> J[Bypass flood,
Accept]
I -- Non --> K{Flood actif ?}
K -- Oui : check_key OK --> L[Accept]
K -- Oui : refusé --> X4[Drop : FloodLimit]
K -- Non --> L
L --> M[ClientIdentity créé,
handshake russh]
M --> N[Tentatives auth]
N --> O[AuthTracker.record_auth_attempt]
O --> O1[Persiste dans store]
O --> O2[Émet event JSON sur sink]
O --> O3[Notifie chaque détecteur]
O3 --> P1[BruteForce / UserEnum / KeyScan]
P1 --> Q{Seuil atteint ?}
Q -- Oui --> R[BanManager.auto_ban]
R --> S[Insert ban + event IpBanned]
Q -- Non --> Z[Fin]
Trois plans qui interagissent¶
| Plan | Composants | Caractéristique principale |
|---|---|---|
| Synchrone, hot path | SecureListener.accept, BanManager.effective_status, ConnectionFloodDetector.check |
Lock-free (DashMap, GCRA), aucun appel DB tant que le store est memory ou hybrid |
| Asynchrone, push | AuthTracker.record_auth_attempt → détecteurs → BanManager.auto_ban |
Tâches Tokio courtes, jamais bloquantes |
| Persistant | HybridStore, PostgresStore |
Write-through périodique, rehydrate au boot |
L'invariant clé : le hot path ne touche jamais directement la DB. Si PostgreSQL tombe, le listener continue à servir avec sa vue mémoire des bans/ACL, et la prochaine écriture admin sera mise en attente (HybridStore) ou échouera proprement (Postgres direct).
4. Les décisions de conception¶
4.1 Listener wrapper plutôt que middleware russh¶
ssh-guard intercale SecureListener entre TcpListener et russh. Trois raisons :
- Beaucoup de drops (flood, ban) doivent se faire avant d'allouer un handler
russh(RAM, threads). Un middlewarerusshcoûterait des allocations inutiles à chaque scan de port. - L'extraction d'IP via PROXY protocol nécessite de peeker les premiers octets du
TcpStreamavant querusshne le voie — un middleware de niveau session ne peut pas faire ça. - Découplage strict : ssh-guard ne dépend pas de
russh(testable en standalone, réutilisable si on remplace la lib SSH).
4.2 Push-based detectors¶
Chaque détecteur expose async fn on_event(event: &GuardEvent). L'AuthTracker les appelle après chaque événement. Avantages :
- Pas de scheduler de fond à entretenir. Le détecteur se réveille uniquement quand il y a quelque chose à analyser.
- Idempotence facile : un second appel après franchissement du seuil retombe sur un
auto_banno-op (déjà banni). - Test déterministe : on injecte des événements synthétiques et on observe le sink — pas de
tokio::time::sleepà attendre.
Coût : chaque détecteur fait une lecture du store pour calculer son agrégat. C'est acceptable car on ne déclenche qu'aux échecs d'auth (rare) et que l'agrégat est borné par la fenêtre.
4.3 Priorité ACL : deny > auto_ban > allow > default¶
L'ordre est exécuté tel quel dans BanManager.effective_status. Conséquences :
denyadmin > tout : un opérateur peut brûler une IP même si elle a été allowlistée par erreur ailleurs.auto_ban>allow: un ban auto déjà posé ne saute pas si on allowliste après coup. Pour le lever, il faut explicitementunban. C'est un choix conservateur : un allow ne doit pas effacer la trace d'un comportement passé.allow>default: une IP allowlistée bypasse les détecteurs (mais ses événements restent loggés pour audit).
L'invariant auto_ban no-op si IP allowlistée est un garde-fou de défense en profondeur : si un détecteur oublie un jour de consulter l'ACL avant d'appeler auto_ban, le BanManager refuse quand même.
4.4 Fail-open sur erreur de store¶
Quand BanManager.effective_status reçoit Err(StoreError), le listener log warn et laisse passer. C'est un compromis :
- Pour : une panne PostgreSQL n'arrête pas le service Git en lecture/push (les opérateurs ne sont pas réveillés à 3h du matin pour une indisponibilité partielle).
- Contre : pendant la fenêtre de panne, un attaquant pourrait passer (mais l'auth
russhreste opérationnelle, donc l'attaquant ne devient utilisateur que s'il connaît une clé valide).
Le compromis a été pris sciemment : il est documenté, et on peut le renverser en remplaçant Ok(...) par return Ok(self.drop_reason(ip, DropReason::Banned).await) dans la branche Err si une instance préfère fail-closed.
4.5 Dry-run avec émission d'événements¶
SSH_GUARD_DRY_RUN=true désactive la persistance des bans mais continue à émettre les événements IpBanned. C'est le mode parfait pour :
- valider un nouveau seuil en prod sans risquer de bannir un utilisateur ;
- alimenter fail2ban (qui ferait le ban réel via UFW/iptables) tout en laissant ssh-guard observer ;
- répliquer le comportement d'un détecteur dans un environnement de pré-prod.
4.6 Stockage hybride par défaut¶
Trois backends, mais le défaut hybrid est presque toujours le bon :
- les lectures hot path passent par DashMap (latence µs) ;
- chaque écriture est synchrone côté mémoire + asynchrone vers PostgreSQL (write-through) ;
- au boot,
rehydraterepeuple la mémoire depuis la DB en une seule transaction. Si la DB est vide ou inaccessible, on démarre avec une mémoire vide et un warn (fail-open au boot aussi).
Le mode memory est réservé aux tests et au profil private (réseau interne, on accepte de perdre l'historique au restart). Le mode postgres direct existe pour les rares cas où une instance multi-noeuds n'a pas de cache RAM cohérent et veut tout passer par la DB.
4.7 Schéma JSON stable¶
#[serde(tag = "event", rename_all = "snake_case")] produit {"event":"<nom>","ts":"...","ip":"...",...}. La règle inviolable :
- ajouter un nouveau variant ou un nouveau champ optionnel : OK ;
- renommer un variant ou un champ existant : breaking change → nouveau nom + period de transition.
C'est ce qui rend les filtres fail2ban et les requêtes Loki stables dans le temps. Un test (event_name_matches_serde_tag) garantit que le nom Rust et le tag JSON ne divergent pas.
5. Ce que ssh-guard ne fait pas (volontairement)¶
| Hors périmètre | Pourquoi | Couvert par |
|---|---|---|
| Inspection des payloads SSH | Niveau TCP uniquement, ssh-guard n'a pas la clé d'hôte | russh lui-même |
| Authentification utilisateur | Domaine de gitrust-core::SshKeyService |
gitrust-ssh |
| Limites de débit applicatives (push trop gros) | Concerne le pack-protocol, pas la connexion | gitrust-git |
| Distribution multi-noeuds | Pas d'algorithme de gossip, pas de Raft | À traiter par le store backend (Postgres partagé) |
| GeoIP / blocage par pays | Décision politique, pas technique | Reverse-proxy ou firewall en amont |
6. Implications pour les contributeurs¶
Quand ajouter un détecteur¶
Si un nouveau motif d'attaque émerge (ex. : « attaques par chronométrie sur les fingerprints »), suivre ce squelette :
- Ajouter un fichier
src/detector/<nom>.rscalqué surbrute_force.rs. - Implémenter
pub async fn on_event(&self, event: &GuardEvent)qui filtre les événements pertinents et lit l'agrégat viaGuardStore. - Ajouter une variante
BanReason::<Nom>et unGuardEvent::<Nom>Detected { ... }dansevents.rs. Mettre à jourevent_nameet le testevent_name_matches_serde_tag. - Câbler le détecteur dans
runtime.rs::build(instanciation conditionnelle sithreshold.is_disabled()). - Câbler dans
tracker.rs(ajout d'unOption<Arc<NouveauDetector>>+ appel dansrecord_auth_attempt). - Documenter le nouveau seuil dans
config.rs(env var + preset par profil) et dans la page admin de référence.
Quand modifier le format d'événement¶
Ne pas. Si un champ doit changer de type ou de sens, créer un nouveau variant et garder l'ancien jusqu'à période de transition explicite. Le test de stabilité du tag est là pour casser la PR si quelqu'un le tente sans s'en rendre compte.
Quand changer une priorité ACL¶
Pratiquement jamais. La priorité actuelle (deny > auto_ban > allow > default) est un consensus défensif. Si une PR la modifie, exiger un dossier d'opportunité avec attaques traitées et nouveaux compromis acceptés.
7. Vérifier votre compréhension¶
- Une connexion arrive avec un en-tête PROXY v2 valide depuis
192.168.10.5. La config estSSH_GUARD_PROFILE=nginx(donctrusted_proxies=127.0.0.1/32, ::1/128). Quel est l'AcceptOutcome? Pourquoi ? - Une IP est dans la denylist admin et vient d'être bannie automatiquement pour brute force. Quel
EffectiveStatusretourneeffective_status? L'ordre des deux lookups dans le code change-t-il quelque chose ? - PostgreSQL tombe pendant 30 minutes. Une attaque brute-force démarre depuis une IP nouvelle. Que se passe-t-il : (a) au moment de l'accept, (b) au moment où le détecteur lit
count_auth_failures?
8. Pour aller plus loin¶
- Crate gitrust-ssh-guard — référence structurelle (modules, types, API publique)
- Architecture des crates — position dans le graphe de dépendances
- Vue d'ensemble de l'architecture — où ssh-guard s'insère dans le runtime gitrust
- ssh-guard : détection d'attaques SSH — vue admin de la chaîne de détection
- Conformité ANSSI PA-074 — directives applicables au code de la crate