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_server pour que SecureListener et AuthTracker partagent les mêmes BanManager / 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 un rehydrate au 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 :

  1. GuardConfig::from_env() (lecture + validation).
  2. build_sink(&config) selon LogTarget.
  3. Construction du store selon SSH_GUARD_STORE_BACKEND :
  4. memoryMemoryStore (rien en DB) ;
  5. postgresPostgresStore (chaque écriture en DB) ;
  6. hybridHybridStore (RAM chaude + flush write-through périodique + rehydrate au boot pour repeupler la mémoire depuis la DB).
  7. Instanciation du BanManager.
  8. Instanciation des détecteurs (chaque détecteur dont le seuil est désactivé — u32::MAX — n'est pas instancié).
  9. Construction de l'AuthTracker avec les détecteurs actifs.
  10. Construction de ProxyListenerConfig si proxy_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 :

  1. tcp.accept()(stream, peer).
  2. resolve_real_ip(stream, peer.ip()) :
  3. profil direct (pas de proxy_config) → IP = peer.ip() ;
  4. profil proxifié → vérifie que peer.ip()trusted_proxies, puis parse l'en-tête PROXY pour extraire l'IP cliente réelle ;
  5. en mode strict, un parsing en échec retourne Err(DropReason::ProxyHeaderInvalid|Missing|UntrustedProxy) ; en mode souple, fallback sur peer.ip() avec warn.
  6. ban_manager.effective_status(ip) :
  7. DeniedByAclDropped { reason: Banned } ;
  8. BannedAuto(_)Dropped { reason: Banned } ;
  9. AllowListedAccepted immédiat (bypass flood) ;
  10. Normal → étape 4.
  11. Si flood actif : flood.check(ip). Sur false → Dropped { reason: FloodLimit } (les events sont déjà émis par flood.check).
  12. Sinon : construction du ClientIdentity, émission de ConnectionAccepted, retour Accepted.

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

pub enum EffectiveStatus {
    Normal,
    AllowListed,
    DeniedByAcl,
    BannedAuto(Box<BanEntry>),
}

Priorité de résolution dans effective_status(ip) :

  1. acl_match(ip) == Some(Deny)DeniedByAcl.
  2. bans_covering(ip) non vide → BannedAuto(ban).
  3. acl_match(ip) == Some(Allow)AllowListed.
  4. Sinon → Normal.

Un appel auto_ban(ip, reason) est no-op dans trois cas :

  • dry_run actif (un événement IpBanned est 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 :

  1. Construit GuardEvent::AuthSucceeded ou AuthFailed selon outcome.
  2. store.record_event(&event) — alimente la table consultée par les détecteurs.
  3. sink.emit(&event) — alimente fail2ban / observabilité.
  4. 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 :

{"event":"<nom_variant>","ts":"...","ip":"...", ...}

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