<index> / <wazabiedr> / agent
[ en | fr ]
┌───────────────────────┐
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
│                       │
└───────────────────────┘
Partie 5 — L'agent user mode
~ lululufr
SOMMAIRE
  0  le contrat de l'agent
  1  layout des modules
  2  ouvrir et pomper
  3  croissance de buffer à la demande
  4  le spool local
  5  le shipper
  6  le serveur de plugins (référence en avant)
  7  sémantique de shutdown

──[ 0. Le Contrat de L'agent ]──

L'agent est un service Windows (un processus user mode long-running géré par le 
Service Control Manager — *SCM* : le composant Windows qui démarre les services
au boot, les redémarre sur échec, et leur donne leur propre environnement
session-0) qui s'assied entre le driver kernel et le serveur. Ses obligations de
surface sont :
    1. Ouvrir \.WazabiEDR, émettre IOCTL_WEDR_GET_EVENT en
       boucle serrée, et décoder chaque événement que le driver
       retourne.
    2. Écrire chaque événement décodé comme ligne JSON dans un
       spool local persistant (séquence de fichiers sur disque
       qui ressemble à une queue).
    3. Périodiquement sceller les segments de spool clos en
       batchs compressés et les expédier à un serveur distant
       via HTTPS.
    4. Héberger un serveur de named pipe à travers lequel les
       plugins (Partie 7) peuvent soumettre leurs propres
       événements dans le même pipeline.
    5. Fournir un chemin de shutdown propre qui flush tout ce
       qui est en vol quand le service est arrêté ou que l'hôte
       reboot.

L'agent tourne en `LOCAL_SYSTEM` parce qu'il a besoin d'ouvrir le device du 
driver — dont l'ACL n'accorde l'accès qu'à `SYSTEM` — et parce qu'il lit le
répertoire de manifests de plugins dont l'accès en écriture est restreint aux
Administrateurs.
──[ 1. Layout des Modules ]──
   WazabiEDR_Agent/
   └── src/
       ├── main.rs            câblage
       ├── config.rs          parse %ProgramData%\WazabiEDR\agent.json
       ├── shutdown.rs        handler Ctrl+C → drapeau SHUTDOWN
       ├── ipc/               IPC côté driver
       │   ├── device.rs      ouverture + pump loop
       │   ├── events.rs      miroir des structs du wire format
       │   ├── parser.rs      octets → événement typé
       │   └── json.rs        événement typé → ligne NDJSON
       ├── spool/             persistance sur disque
       │   ├── writer.rs      thread qui possède le fichier actif
       │   └── file.rs        rotation par fichier
       ├── shipper/           upload HTTPS
       │   ├── run.rs         thread shipper
       │   └── secret.rs      credentials HMAC + bearer
       ├── plugin/            serveur de named pipe (Partie 7)
       └── util/
Chaque module top-level possède une responsabilité. `main.rs` est configuration 
et câblage seulement — il parse la config, ouvre le driver, spawn les threads
writer du spool et shipper, spawn le serveur de plugins, puis entre dans le pump
loop sur le thread principal.
──[ 2. Ouvrir et Pomper ]──

L'ouverture du device est un `CreateFileW` de routine avec un détail à signaler 
:
pub fn open_device() -> io::Result<HANDLE> {
    let path = to_wide_nul(r"\\.\WazabiEDR");
    let handle = unsafe {
        CreateFileW(
            path.as_ptr(),
            GENERIC_READ,
            FILE_SHARE_READ | FILE_SHARE_WRITE,
            ptr::null(),
            OPEN_EXISTING,
            0,
            ptr::null_mut(),
        )
    };
    if handle == INVALID_HANDLE_VALUE {
        let err = unsafe { GetLastError() };
        return Err(io::Error::from_raw_os_error(err as i32));
    }
    Ok(handle)
}
`GetLastError` est lu *immédiatement* après le check d'échec, avant tout autre 
appel Win32. `GetLastError` est thread-local mais muté par chaque point d'entrée
Win32 — un seul appel d'allocation, de formatage, ou un toucher au logger entre
les deux peut l'écraser avec une valeur sans rapport. Le traiter comme une
ressource fragile et le snapshotter à la première opportunité, c'est la
discipline standard. Le pump loop est court :
const IOCTL_WEDR_GET_EVENT: u32 = 0x0022_6000;
const INITIAL_BUF: usize = 4096;

let mut buf = vec![0u8; INITIAL_BUF];
while !SHUTDOWN.load(Ordering::Acquire) {
    let mut returned: u32 = 0;
    let ok = unsafe {
        DeviceIoControl(
            handle,
            IOCTL_WEDR_GET_EVENT,
            ptr::null(), 0,
            buf.as_mut_ptr() as *mut _, buf.len() as u32,
            &mut returned, ptr::null_mut(),
        )
    };
    if ok == FALSE as i32 {
        // gestion d'erreur — voir section suivante
        continue;
    }
    let payload = &buf[..returned as usize];
    // spool + (optionnel) print
}
L'IOCTL est bloquant by design — le mécanisme `STATUS_PENDING` de la Partie 4 
est ce qui permet à cet appel de rester là indéfiniment jusqu'à ce que le driver
ait un événement. Aucune primitive d'attente supplémentaire n'est nécessaire en
user mode ; pas de select, pas de timer, pas d'I/O overlapped. Le syscall
bloquant, c'est l'attente. Le code IOCTL (`0x0022_6000`) est dupliqué depuis le driver, avec un commentaire
qui pointe sur la source. La duplication est volontaire — partager la constante
via une crate commune tirerait les dépendances transitives `wdk-sys` du driver
dans le build de l'agent (la seule raison pour laquelle la constante est
atteignable dans le driver est son site de définition près de
`IOCTL_WEDR_GET_EVENT`), et le coût de dupliquer un `u32` est négligeable.
──[ 3. Croissance de Buffer à la Demande ]──

Si le driver pop un événement plus gros que le buffer courant de l'agent, 
l'IOCTL complète avec `STATUS_BUFFER_TOO_SMALL` (mappé vers le Win32
`ERROR_INSUFFICIENT_BUFFER`) et `Information` (rendu dans `returned`) posée à la
taille requise :
if err == ERROR_INSUFFICIENT_BUFFER {
    let needed = returned.max(buf.len() as u32 * 2) as usize;
    eprintln!("[agent] buffer too small, growing {} → {}", buf.len(), needed);
    buf.resize(needed, 0);
    continue;
}
La règle de croissance, c'est "au moins la taille demandée par le driver, mais 
au minimum le double de la capacité courante". Le doublement évite de réallouer
à chaque itération si une série d'événements légèrement plus grands suit.
L'événement n'est pas perdu sur ce chemin — par la Partie 4, la réponse
buffer-too-small sur un chemin IRP-parkée drop l'événement courant avec un
incrément de `drop_count`, mais sur le chemin pop-de-ring normal l'événement
reste dans la queue et l'agent le récupère à l'itération suivante. En pratique le 4 Kio initial dépasse confortablement le plus gros événement
courant (`ProcessCreateEvent` à environ 1 Kio), donc cette branche ne se
déclenche effectivement jamais. Elle est présente pour la compatibilité
ascendante avec de futurs types d'événements qui pourraient porter de plus gros
payloads inline.
──[ 4. Le Spool Local ]──

Chaque événement reçu coule à travers un seul pipeline :
   octets driver ─parse_and_decode─> event typé ─encode_kernel_event─> ligne NDJSON
                                                                          │
                                                                          ▼
                                                                  thread writer du spool
                                                                          │
                                                                          ▼
                                                              active.ndjson (non compressé)
                                                                          │
                                                              rotation quand :
                                                                · taille fichier > max_bytes_per_file
                                                                · âge fichier  > max_age
                                                                          │
                                                                          ▼
                                                                 batch-NNN.ndjson
                                                                          │
                                                                          ▼
                                                                 batch-NNN.ndjson.zst
                                                                          │
                                                                          ▼
                                                                  shipper le prend
Le format est NDJSON (*Newline-Delimited JSON* : un objet JSON indépendant par 
ligne, le format de facto pour le log shipping). Il est non compressé pendant la
phase active pour que chaque ligne soit append atomiquement et récupérable après
un crash (une dernière ligne tronquée est détectable et skippable). La
compression se passe à la rotation — le `batch-NNN.ndjson` clos est passé à
travers zstd (*zstd* : un compresseur sans perte rapide avec des niveaux de
vitesse/ratio ajustables) vers `batch-NNN.ndjson.zst`. Trois déclencheurs de rotation :
    max_bytes_per_file — rotation quand active.ndjson dépasse
                         cette taille
    max_age            — rotation quand active.ndjson atteint
                         cet âge (pour qu'un hôte au repos ne
                         garde pas des événements dans le
                         fichier actif indéfiniment)
    max_total_bytes    — cap au niveau répertoire ; les plus
                         vieux batchs sont retirés quand la
                         racine du spool dépasse ça

Le troisième déclencheur compte sur les hôtes où aucun shipper n'est configuré — 
le répertoire grandirait sinon sans borne. La politique, c'est de perdre de la
vieille télémétrie plutôt que de remplir le disque ; l'agent survit toujours,
l'opérateur peut re-prioritiser. Le writer de spool est un thread dédié alimenté par un channel mpsc (*mpsc,
multi-producer single-consumer channel* : la queue inter-thread standard de
Rust, utilisée ici pour découpler le pump de l'I/O disque). Le pump émet
`try_submit` et ne bloque jamais : si le channel est plein (writer en retard),
l'événement est droppé et un seul warning stderr est émis pour la vie de
l'agent. La règle "le pump loop ne bloque jamais sur de l'I/O disque" est ce qui
permet à l'agent de tenir le débit pire-cas du driver.
──[ 5. Le Shipper ]──

Si la section `shipper` est présente dans `agent.json`, un second thread 
surveille les répertoires de spool pour les fichiers `.zst` :
match cfg.shipper {
    Some(sc) => {
        let dirs = vec![cfg.agent.spool_dir.clone(), plugin_spool_dir.clone()];
        spawn_shipper(sc, dirs).ok()
    }
    None => None,  // mode spool-only
};
Chaque batch est signé avec un HMAC (*HMAC, Hash-based Message Authentication 
Code* : construction à clé symétrique sur une fonction de hash utilisée ici pour
lier le contenu du batch à un secret par-agent) et POSTé au serveur. Issues :
    HTTP 2xx          — batch supprimé du disque
    HTTP 5xx, réseau  — batch laissé en place ; la prochaine
                        passe retente
    HTTP 4xx          — batch renommé en `.poisoned` et ignoré
                        pour toujours (le serveur a rejeté son
                        contenu ; retenter boucle)
    HTTP 401 / 403    — comme 4xx, mais en plus mettre le
                        shipper en pause pour backoff
                        (l'opérateur doit probablement
                        rafraîchir les credentials)

L'agent tourne fonctionnellement sans shipper. Les installations air-gappées 
laissent la section `shipper` hors d'`agent.json` et produisent les mêmes batchs
`.zst` ; un opérateur peut les copier hors-hôte via n'importe quel canal
latéral. Le shipper est une commodité, pas de l'infrastructure.
──[ 6. Le Serveur de Plugins (Référence en Avant) ]──

`plugin::spawn_server` ouvre le named pipe `\.pipeWazabiEDR_plugin` et accepte 
les connexions de plugins. Chaque handshake réussi donne une session dont les
événements coulent à travers le même pipeline spool/shipper que les événements
kernel, dans un sous-répertoire `plugins/` séparé. Le protocole, le SDK contre lequel les plugins sont écrits, et le mécanisme de
manifest qui contrôle *quels* plugins peuvent compléter le handshake sont le
sujet des Parties 7, 8 et 9 respectivement.
──[ 7. Sémantique de Shutdown ]──

`shutdown.rs` installe un handler de contrôle de console Windows (via 
`SetConsoleCtrlHandler`) et un handler stop du Service Control Manager qui
basculent tous les deux le même drapeau atomique :
pub static SHUTDOWN: AtomicBool = AtomicBool::new(false);
Chaque boucle long-running dans l'agent checke le drapeau à sa frontière 
naturelle d'itération :
    Pump loop          — vérifié avant chaque émission d'IOCTL
    Writer de spool    — vérifié entre les drainages de channel
    Shipper            — vérifié entre les polls de répertoire
    Serveur de plugins — vérifié avant chaque accept de pipe

Pas de channel de broadcast, pas de primitive select, pas de pyramide condvar 
cross-thread. Un atomique, pollé à des points bien définis. Le coût d'un design
polled-atomic, c'est une latence bornée au shutdown (pire cas : la durée d'un
syscall bloquant en vol), et ce coût est acceptable pour un processus arrêté une
fois par uptime. La séquence de shutdown propre est :
    1. Le drapeau SHUTDOWN bascule.
    2. Le pump loop se réveille — soit parce que la prochaine
       IOCTL complète, soit parce que dispatch_cleanup dans le
       driver annule l'IRP en attente (Partie 4) quand le
       handle de l'agent se ferme.
    3. Le writer de spool draine sa queue en vol et ferme le
       fichier actif (rotation et compression à la sortie).
    4. Le shipper termine son upload courant ou abandonne sur
       timeout réseau, puis sort.
    5. Le serveur de plugins envoie un goodbye à chaque session
       active et ferme le serveur de pipe.

Prochain post : le côté serveur. FastAPI, PostgreSQL, OpenSearch, Redis, le 
chemin d'ingestion, et les raisons pour lesquelles chaque store est là où il
est.