Crate gitrust-ssh-guard¶
Référence structurelle de la crate de durcissement SSH. Pour la motivation et le rationale de design, voir Conception de ssh-guard. Pour l'exploitation côté admin, voir Configurer ssh-guard.
1. Position dans l'arborescence¶
crates/
├── gitrust-ssh-guard/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs (re-exports publics)
│ ├── config.rs (SSH_GUARD_*, profils, validation)
│ ├── runtime.rs (GuardHandles : assemblage)
│ ├── listener.rs (SecureListener, AcceptOutcome)
│ ├── proxy.rs (parseur PROXY protocol v1/v2)
│ ├── identity.rs (ClientIdentity)
│ ├── tracker.rs (AuthTracker, dispatch détecteurs)
│ ├── ban.rs (BanManager, EffectiveStatus)
│ ├── events.rs (GuardEvent, GuardEventSink)
│ ├── sinks.rs (TracingSink, FileSink, MultiSink)
│ ├── detector/
│ │ ├── mod.rs
│ │ ├── brute_force.rs
│ │ ├── user_enumeration.rs
│ │ ├── key_scanning.rs
│ │ └── connection_flood.rs
│ └── store/
│ ├── mod.rs (trait GuardStore)
│ ├── entities.rs (modèles SeaORM)
│ ├── memory.rs (MemoryStore — DashMap)
│ ├── postgres.rs (PostgresStore — write-through)
│ └── hybrid.rs (HybridStore — RAM chaude + DB)
2. Tableau des modules¶
| Module | Rôle | Types principaux |
|---|---|---|
config |
Lecture des env vars SSH_GUARD_*, sélection du profil de déploiement, validation de cohérence (ex. : un profil proxifié sans trusted_proxies est rejeté au démarrage). |
GuardConfig, DeploymentProfile, ProxyProtocol, StoreBackend, LogFormat, LogTarget, DetectorThreshold, ConfigError |
runtime |
Assemble tous les composants à partir de GuardConfig + connexion DatabaseConnection. Le binaire en construit une seule instance et la partage entre le serveur SSH et le routeur Axum admin. |
GuardHandles, BuildError |
listener |
Wrappe un tokio::net::TcpListener : extraction d'IP réelle (PROXY ou peer_addr), consultation de l'ACL/ban, rate-limit flood, puis remise du TcpStream au handler russh. |
SecureListener, AcceptOutcome, AcceptError, ProxyListenerConfig |
proxy |
Parseur PROXY protocol v1 (texte HAProxy legacy) et v2 (binaire nginx stream / HAProxy moderne) avec timeout dédié. | parse_header, ParsedHeader, ProxyError |
identity |
Identité d'un client SSH au fil de la session : IP, session_id (UUID), nom d'utilisateur et fingerprint de clé renseignés au moment de l'auth. |
ClientIdentity |
tracker |
Reçoit les événements d'auth émis par gitrust-ssh, les persiste dans le store, les diffuse au sink, puis appelle on_event sur chaque détecteur actif. |
AuthTracker, AuthOutcome |
ban |
Compose ACL admin (allow/deny) et bans actifs en un statut effectif. Pose les bans auto (TTL) et manuels (CIDR), gère le unban. |
BanManager, EffectiveStatus |
events |
Définition stable du schéma JSON émis. Toute évolution incompatible doit créer un nouveau variant plutôt que renommer un champ existant. | GuardEvent, GuardEventSink, AuthMethod, BanReason, DropReason |
sinks |
Implémentations de GuardEventSink : tracing (stderr / journald), fichier en append (ligne JSON), fan-out vers plusieurs sinks. |
TracingSink, FileSink, MultiSink, build_sink |
detector::brute_force |
Compte les AuthFailed par IP dans la fenêtre, déclenche auto_ban(BruteForce) au seuil. |
BruteForceDetector |
detector::user_enumeration |
Compte les usernames distincts essayés par IP dans la fenêtre. | UserEnumerationDetector |
detector::key_scanning |
Compte les fingerprints de clé distincts essayés par IP dans la fenêtre. | KeyScanningDetector |
detector::connection_flood |
Token bucket GCRA (governor) keyé par IP. Pas de ban persistant : drop immédiat au listener. |
ConnectionFloodDetector |
store |
Trait abstrait pour bans, ACL et événements d'auth. Trois implémentations interchangeables. | GuardStore, MemoryStore, PostgresStore, HybridStore, BanEntry, AclEntry, AclKind, RehydrateStats, StoreError |
3. Surface API publique (lib.rs)¶
// Assemblage et runtime
pub use runtime::{BuildError, GuardHandles};
// Configuration
pub use config::{
ConfigError, DeploymentProfile, DetectorThreshold, GuardConfig,
LogFormat, LogTarget, ProxyProtocol, StoreBackend,
};
// Listener
pub use listener::{AcceptError, AcceptOutcome, ProxyListenerConfig, SecureListener};
// Identité et tracking
pub use identity::ClientIdentity;
pub use tracker::{AuthOutcome, AuthTracker};
// Décision de ban
pub use ban::{BanManager, EffectiveStatus};
// Détecteurs
pub use detector::{
BruteForceDetector, ConnectionFloodDetector,
KeyScanningDetector, UserEnumerationDetector,
};
// Événements et sinks
pub use events::{AuthMethod, BanReason, DropReason, GuardEvent, GuardEventSink};
pub use sinks::{build_sink, FileSink, MultiSink, TracingSink};
// Stockage
pub use store::{
AclEntry, AclKind, BanEntry, GuardStore, HybridStore,
MemoryStore, PostgresStore, RehydrateStats, StoreError,
};
Lints actifs sur la crate (alignés ANSSI PA-074) :
#![forbid(unsafe_code)]
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::mem_forget
)]
4. GuardHandles — point d'entrée du runtime¶
GuardHandles est l'objet construit une fois au démarrage du binaire, puis cloné (tous les champs sont Arc<…>) là où il est nécessaire :
- vers
gitrust_ssh::server::start_serverpour queSecureListeneretAuthTrackerpartagent les mêmesBanManager/GuardStore; - vers le routeur Axum via
axum::Extension, pour que les actions admin (ajouter une IP en denylist, lever un ban) soient immédiatement visibles par le listener — sans attendre unrehydrateau prochain redémarrage.
#[derive(Clone)]
pub struct GuardHandles {
pub config: GuardConfig,
pub store: Arc<dyn GuardStore>,
pub sink: Arc<dyn GuardEventSink>,
pub ban_manager: Arc<BanManager>,
pub tracker: Arc<AuthTracker>,
pub flood: Option<Arc<ConnectionFloodDetector>>,
pub proxy_config: Option<ProxyListenerConfig>,
}
impl GuardHandles {
pub async fn build(db: DatabaseConnection) -> Result<Self, BuildError>;
}
build enchaîne :
GuardConfig::from_env()(lecture + validation).build_sink(&config)selonLogTarget.- Construction du store selon
SSH_GUARD_STORE_BACKEND: memory→MemoryStore(rien en DB) ;postgres→PostgresStore(chaque écriture en DB) ;hybrid→HybridStore(RAM chaude + flush write-through périodique +rehydrateau boot pour repeupler la mémoire depuis la DB).- Instanciation du
BanManager. - Instanciation des détecteurs (chaque détecteur dont le seuil est désactivé —
u32::MAX— n'est pas instancié). - Construction de l'
AuthTrackeravec les détecteurs actifs. - Construction de
ProxyListenerConfigsiproxy_protocol != Disabled.
5. SecureListener — séquence d'accept¶
pub enum AcceptOutcome {
Accepted { stream: TcpStream, identity: ClientIdentity },
Dropped { ip: IpAddr, reason: DropReason },
}
impl SecureListener {
pub async fn accept(&self) -> Result<AcceptOutcome, AcceptError>;
}
Étapes appliquées à chaque connexion entrante :
tcp.accept()→(stream, peer).resolve_real_ip(stream, peer.ip()):- profil direct (pas de
proxy_config) → IP =peer.ip(); - profil proxifié → vérifie que
peer.ip()∈trusted_proxies, puis parse l'en-tête PROXY pour extraire l'IP cliente réelle ; - en mode
strict, un parsing en échec retourneErr(DropReason::ProxyHeaderInvalid|Missing|UntrustedProxy); en mode souple, fallback surpeer.ip()avec warn. ban_manager.effective_status(ip):DeniedByAcl→Dropped { reason: Banned };BannedAuto(_)→Dropped { reason: Banned };AllowListed→Acceptedimmédiat (bypass flood) ;Normal→ étape 4.- Si
floodactif :flood.check(ip). Sur false →Dropped { reason: FloodLimit }(les events sont déjà émis parflood.check). - Sinon : construction du
ClientIdentity, émission deConnectionAccepted, retourAccepted.
Toute erreur du store est traitée en fail-open (warn + on laisse passer), pour ne pas couper le service quand PostgreSQL est indisponible.
6. BanManager — priorités d'ACL¶
Priorité de résolution dans effective_status(ip) :
acl_match(ip) == Some(Deny)→DeniedByAcl.bans_covering(ip)non vide →BannedAuto(ban).acl_match(ip) == Some(Allow)→AllowListed.- Sinon →
Normal.
Un appel auto_ban(ip, reason) est no-op dans trois cas :
dry_runactif (un événementIpBannedest tout de même émis pour fail2ban / observabilité) ;- l'IP est allowlistée ;
- un ban auto actif couvre déjà l'IP (évite le spam d'événements
IpBanned).
Un manual_ban(cidr, reason, ttl) accepte un CIDR (pas seulement un /32) et est permanent si ttl=None. unban(ban_id, by) retire le ban et émet IpUnbanned.
7. AuthTracker — pivot de la chaîne d'événements¶
pub enum AuthOutcome { Success, Failure }
impl AuthTracker {
pub async fn record_auth_attempt(
&self,
identity: &ClientIdentity,
method: AuthMethod,
outcome: AuthOutcome,
);
}
Pour chaque appel :
- Construit
GuardEvent::AuthSucceededouAuthFailedselonoutcome. store.record_event(&event)— alimente la table consultée par les détecteurs.sink.emit(&event)— alimente fail2ban / observabilité.- Pour chaque détecteur configuré :
detector.on_event(&event).await.
Côté gitrust-ssh, le handler russh appelle record_auth_attempt après chaque tentative d'authentification (clé publique, password, keyboard-interactive).
8. Schéma JSON des événements¶
Tous les GuardEvent se sérialisent avec #[serde(tag = "event", rename_all = "snake_case")]. La forme stable est :
Pour le détail des champs par variant et leur usage côté admin (fail2ban, SIEM), voir Événements ssh-guard (JSON).
Convention de stabilité :
- ajouter un nouveau variant n'est pas un breaking change pour les consommateurs ;
- ajouter un champ optionnel à un variant existant ne l'est pas non plus ;
- renommer un champ ou un variant est un breaking change et nécessite un nouveau nom.
9. Backends de stockage (GuardStore)¶
| Backend | Vie des bans/ACL | Latence lecture | Cas d'usage |
|---|---|---|---|
MemoryStore |
Perdues au restart | DashMap, lock-free | Tests, profil private, dev |
PostgresStore |
Persistantes | Round-trip DB par lecture | Audits stricts, instances multi-noeuds (sans cache RAM) |
HybridStore |
RAM chaude + write-through DB, rehydrate au boot |
DashMap | Défaut recommandé : performance + persistance |
Le trait GuardStore expose : acl_match, bans_covering, is_banned, insert_ban, unban, list_active_bans, insert_acl, record_event, count_auth_failures, count_distinct_users, count_distinct_keys.
10. Intégration côté gitrust-ssh¶
Pseudo-code condensé du wiring effectif (crates/gitrust-ssh/src/server.rs) :
let secure = SecureListener::new_with_proxy(
tcp_listener,
guard.ban_manager.clone(),
guard.flood.clone(),
guard.sink.clone(),
guard.proxy_config.clone(),
);
loop {
match secure.accept().await? {
AcceptOutcome::Accepted { stream, identity } => {
let session = GitSession::new_with_guard(
db.clone(), ssh_config.clone(), ci_tx.clone(),
identity, guard.tracker.clone(),
);
tokio::spawn(async move { russh::server::run_stream(/* ... */).await });
}
AcceptOutcome::Dropped { .. } => {
// L'événement ConnectionDropped a déjà été émis par le listener.
}
}
}
Côté GitSession (handler russh) : à chaque tentative d'auth, l'identité est enrichie (set_username, set_fingerprint) puis tracker.record_auth_attempt(&identity, method, outcome) est appelé.
11. Tests¶
Toute la crate utilise des tests unitaires #[tokio::test] avec un MemoryStore et un RecorderSink qui capture les GuardEvent. Ordre de grandeur : 80+ tests couvrant chaque détecteur, la priorité d'ACL, le mode dry_run, l'idempotence du ban, l'expiration TTL, les cas d'erreur du parseur PROXY, la séparation des budgets de flood par IP.
Les tests d'intégration de bout en bout (PROXY v2 binaire émis par un client de test, déclenchement réel du seuil brute-force depuis gitrust-ssh) vivent dans crates/gitrust-ssh/tests/.
12. Voir aussi¶
- Conception de ssh-guard — pourquoi cette crate, modèle de menace, design async
- Architecture des crates — position de ssh-guard dans le graphe de dépendances
- Configurer ssh-guard — recettes par profil de déploiement
- Variables d'environnement — section SSH_GUARD_*
- Événements ssh-guard (JSON)