ajout de la crate ssh-guard
EBO <eric.bouhana@softalys.com> committé le 2026-04-19 22:41
31864c066eaafb065b4f92085a8a3ab3dd3b006a
1 parent(s)
28 fichiers modifiés
+2178
-56
M
.gitignore
+3
-1
@@ -11,10 +11,12 @@ CLAUDE.md
| 11 | 11 | .plans/ |
| 12 | 12 | |
| 13 | 13 | # ============================================================================= |
| 14 | -# Build mdBook | |
| 14 | +# Build mdBook + PDF | |
| 15 | 15 | # ============================================================================= |
| 16 | 16 | book/ |
| 17 | +pdf/ | |
| 17 | 18 | po/messages.pot |
| 19 | +scripts/build-pdf/target/ | |
| 18 | 20 | |
| 19 | 21 | # ============================================================================= |
| 20 | 22 | # Node / Playwright (scripts de capture) |
M
README.md
+90
-6
@@ -83,6 +83,74 @@ Workflow complet : [CONTRIBUTING.md §3](CONTRIBUTING.md#3-workflow-de-traductio
| 83 | 83 | |
| 84 | 84 | --- |
| 85 | 85 | |
| 86 | +## Export PDF | |
| 87 | + | |
| 88 | +Deux implémentations au choix — produit `pdf/documentation-<lang>.pdf` (une par langue). | |
| 89 | + | |
| 90 | +### Option Rust (recommandée) — [`scripts/build-pdf/`](scripts/build-pdf/) | |
| 91 | + | |
| 92 | +Wrapper léger autour de `wkhtmltopdf` (backend par défaut) avec fallback automatique vers `chromium --headless` si wkhtmltopdf n'est pas installé. | |
| 93 | + | |
| 94 | +```bash | |
| 95 | +sudo apt install wkhtmltopdf # ou brew install wkhtmltopdf | |
| 96 | + | |
| 97 | +cargo run --release --manifest-path scripts/build-pdf/Cargo.toml # les 6 langues | |
| 98 | +cargo run --release --manifest-path scripts/build-pdf/Cargo.toml -- fr en # langues spécifiques | |
| 99 | + | |
| 100 | +# Forcer chromium si wkhtmltopdf est trouvé mais non souhaité : | |
| 101 | +PDF_BACKEND=chromium cargo run --release --manifest-path scripts/build-pdf/Cargo.toml -- fr | |
| 102 | +``` | |
| 103 | + | |
| 104 | +`wkhtmltopdf` est ~10× plus rapide que Chromium complet sur un `print.html` de 94 pages et évite les crashes V8 sur les documents volumineux. Laisse 3 s à Mermaid pour se rendre via le JS QtWebKit embarqué. | |
| 105 | + | |
| 106 | +### Option Python — [`scripts/build-pdf.py`](scripts/build-pdf.py) | |
| 107 | + | |
| 108 | +```bash | |
| 109 | +# Python 3.9+ uniquement. Pour Python 3.8 : pip install 'weasyprint==60.2' 'pydyf==0.8.0' | |
| 110 | +pip install weasyprint | |
| 111 | + | |
| 112 | +./scripts/build-pdf.py # les 6 langues | |
| 113 | +./scripts/build-pdf.py fr en # langues spécifiques | |
| 114 | +OUT_DIR=./dist ./scripts/build-pdf.py | |
| 115 | +``` | |
| 116 | + | |
| 117 | +Pur Python, pas de binaire externe. **Attention** : weasyprint n'exécute pas le JavaScript, les diagrammes Mermaid restent en source brute. Préférer la version Rust pour un rendu fidèle. | |
| 118 | + | |
| 119 | +--- | |
| 120 | + | |
| 121 | +## SEO & AI Search Readiness | |
| 122 | + | |
| 123 | +Optimisé pour indexation Google + découverte AI search (ChatGPT, Claude, Perplexity). Voir [`seo/AUDIT.md`](seo/AUDIT.md) pour le rapport détaillé (score 88 %). | |
| 124 | + | |
| 125 | +### Artefacts générés automatiquement au build | |
| 126 | + | |
| 127 | +``` | |
| 128 | +book/ | |
| 129 | +├── robots.txt # bots IA autorisés (GPTBot, ClaudeBot, PerplexityBot, Google-Extended) | |
| 130 | +├── llms.txt # manifest llmstxt.org avec 25+ liens directs | |
| 131 | +├── sitemap.xml # index des 6 sitemaps par langue | |
| 132 | +└── <lang>/sitemap.xml # 95 URLs/lang avec hreflang + priorité Diátaxis | |
| 133 | +``` | |
| 134 | + | |
| 135 | +Chaque page HTML contient : | |
| 136 | + | |
| 137 | +- `<link rel="canonical">` pointant vers l'URL publiée | |
| 138 | +- 6 `<link rel="alternate" hreflang="…">` + `x-default` | |
| 139 | +- OpenGraph + Twitter Cards complets | |
| 140 | +- JSON-LD `@graph` : Organization + WebSite + TechArticle | |
| 141 | + | |
| 142 | +Configuration dans [`theme/head.hbs`](theme/head.hbs). Post-traitement dans [`scripts/seo-postbuild.sh`](scripts/seo-postbuild.sh) — appelé automatiquement par `build-all-langs.sh`. | |
| 143 | + | |
| 144 | +### Changer l'URL de publication | |
| 145 | + | |
| 146 | +Par défaut `https://demo.gitrust.eu/docs`. Pour un staging : | |
| 147 | + | |
| 148 | +```bash | |
| 149 | +SITE_BASE_URL=https://staging.example.com/docs ./scripts/build-all-langs.sh | |
| 150 | +``` | |
| 151 | + | |
| 152 | +--- | |
| 153 | + | |
| 86 | 154 | ## Captures d'écran (Playwright) |
| 87 | 155 | |
| 88 | 156 | Les tutoriels `user_manual/` et `administration_manual/` référencent des captures dans `screenshots/`. Le script Playwright s'authentifie sur une instance gitrust et prend les captures automatiquement : |
@@ -103,20 +171,31 @@ girust_doc/
| 103 | 171 | ├── book.toml # config mdBook (thème gitrust, mermaid, i18n) |
| 104 | 172 | ├── src/ # sources mdBook (FR canonique) |
| 105 | 173 | │ ├── SUMMARY.md # table des matières globale |
| 106 | -│ └── introduction.md | |
| 174 | +│ ├── introduction.md | |
| 175 | +│ └── <manuel>/ # symlinks vers les 4 manuels racine | |
| 107 | 176 | ├── user_manual/ # ┐ |
| 108 | 177 | ├── administration_manual/ # ├ 4 manuels × 4 dossiers Diátaxis |
| 109 | 178 | ├── developer_manual/ # ┤ (tutorials/ how-to/ reference/ explanation/) |
| 110 | 179 | ├── template/ # ┘ |
| 111 | 180 | ├── diagrams/ # sources Mermaid partagées (.mmd) |
| 112 | 181 | ├── po/ # traductions gettext (en, de, es, pt, it) |
| 113 | -├── screenshots/ # captures Playwright | |
| 114 | -├── theme/ # thème mdBook (palette gitrust) | |
| 182 | +├── screenshots/ # captures Playwright + placeholders | |
| 183 | +├── theme/ # thème mdBook (palette gitrust + head.hbs SEO) | |
| 184 | +├── seo/ # robots.txt, llms.txt, AUDIT.md | |
| 185 | +├── pdf/ # sortie PDF par langue (gitignoré) | |
| 186 | +├── book/ # sortie mdBook par langue (gitignoré) | |
| 115 | 187 | ├── .dagger/ # module Dagger Python (pipeline CI) |
| 116 | -├── scripts/ # build-all-langs.sh, screenshot-runner/ | |
| 188 | +├── scripts/ | |
| 189 | +│ ├── build-all-langs.sh # builde les 6 langues + SEO postbuild | |
| 190 | +│ ├── build-pdf.py # export PDF (weasyprint, sans JS) | |
| 191 | +│ ├── build-pdf/ # export PDF (Rust → wkhtmltopdf / chromium) | |
| 192 | +│ ├── seo-postbuild.sh # génère sitemap, robots, llms | |
| 193 | +│ └── screenshot-runner/ # Playwright (capture.mjs, capture-usermanual.mjs) | |
| 117 | 194 | ├── .plans/ # plans de travail versionnés |
| 195 | +├── .github/workflows/ # CI GitHub Actions (build + lint) | |
| 118 | 196 | ├── CONTRIBUTING.md # workflow PR, Diátaxis, pédagogie, i18n |
| 119 | -└── .markdownlint.yaml | |
| 197 | +├── .markdownlint.yaml | |
| 198 | +└── .gitignore | |
| 120 | 199 | ``` |
| 121 | 200 | |
| 122 | 201 | --- |
@@ -140,8 +219,13 @@ Lire [CONTRIBUTING.md](CONTRIBUTING.md) avant toute PR. Points clés :
| 140 | 219 | | Pages Markdown | 94 | |
| 141 | 220 | | Stubs restants | 0 | |
| 142 | 221 | | Langues buildées | 6 (fr + en, de, es, pt, it) | |
| 143 | -| Diagrammes Mermaid centralisés | voir `diagrams/` | | |
| 222 | +| URLs indexées (sitemap) | 570 (95 × 6) | | |
| 223 | +| Pages tutorielles avec template pédagogique | 8 (3 user + 3 admin + 2 dev) | | |
| 224 | +| Captures d'écran (Playwright + placeholders) | 42 | | |
| 225 | +| Diagrammes Mermaid centralisés | dans `diagrams/` + inline dans les `.md` | | |
| 144 | 226 | | Pipeline CI | Dagger Python + GitHub Actions YAML | |
| 227 | +| Export PDF | Rust (wkhtmltopdf, défaut) ou Python (weasyprint) → `pdf/` | | |
| 228 | +| SEO score (cf. `seo/AUDIT.md`) | 88 / 100 | | |
| 145 | 229 | |
| 146 | 230 | --- |
| 147 | 231 |
M
administration_manual/explanation/architecture-globale.md
+13
-1
@@ -38,6 +38,7 @@ Un seul processus, mais plusieurs « couloirs » internes. Si l'immeuble ferme (
| 38 | 38 | graph TB |
| 39 | 39 | subgraph "Processus gitrust (binaire unique)" |
| 40 | 40 | AX[Serveur HTTP axum<br/>:4000] |
| 41 | + GUARD[ssh-guard<br/>SecureListener + détecteurs] | |
| 41 | 42 | SSH[Serveur SSH Russh<br/>:2222] |
| 42 | 43 | CIW[Worker CI async<br/>Dagger] |
| 43 | 44 | IMW[Worker Import async<br/>git clone] |
@@ -58,10 +59,13 @@ graph TB
| 58 | 59 | end |
| 59 | 60 | |
| 60 | 61 | BR -->|HTTP/HTTPS via nginx| AX |
| 61 | - GIT -->|SSH :2222| SSH | |
| 62 | + GIT -->|SSH :2222| GUARD | |
| 63 | + GUARD -->|"AcceptOutcome::Accepted"| SSH | |
| 62 | 64 | CI -->|git push → hook| CIW |
| 63 | 65 | |
| 64 | 66 | AX -->|SeaORM| PG |
| 67 | + AX -.->|"admin ACL/ban"| GUARD | |
| 68 | + GUARD -->|Bans, ACL, events| PG | |
| 65 | 69 | SSH -->|Lit authorized_keys| PG |
| 66 | 70 | SSH -->|git-receive-pack| FS |
| 67 | 71 | AX -->|Lit/écrit dépôts| FS |
@@ -143,6 +147,12 @@ Un dépôt existe à deux endroits : en base (`repositories` table) et sur disqu
| 143 | 147 | |
| 144 | 148 | **Conséquence** : toujours sauvegarder et restaurer la base ET les dépôts ensembles (voir [Sauvegarder et restaurer](../how-to/sauvegarder-restaurer.md)). |
| 145 | 149 | |
| 150 | +### ssh-guard intercale un sas devant le serveur SSH | |
| 151 | + | |
| 152 | +Toutes les connexions sur `:2222` passent d'abord par `SecureListener` (ssh-guard). Cette couche extrait l'IP réelle (PROXY protocol si nginx stream est devant), consulte ACL et bans actifs, applique un cap TCP par IP, puis ne remet le `TcpStream` à `russh` que si tout est en règle. Un événement JSON stable est émis pour chaque décision (`connection_accepted`, `connection_dropped`, `auth_failed`, `ip_banned`, …) — pratique pour fail2ban et SIEM. | |
| 153 | + | |
| 154 | +**Conséquence** : modifier `SSH_GUARD_*` dans `.env` change immédiatement le comportement défensif après redémarrage. Les ACL admin (allow/deny par CIDR) sont en revanche modifiables à chaud via l'UI : la table `ssh_guard_acl` est partagée par référence entre le routeur HTTP et le listener SSH. Voir [Configurer ssh-guard](../how-to/configurer-ssh-guard.md) et [ssh-guard : détection d'attaques SSH](ssh-guard-detection.md). | |
| 155 | + | |
| 146 | 156 | ### La clé SSH hôte identifie l'instance |
| 147 | 157 | |
| 148 | 158 | La clé Ed25519 dans `SSH_HOST_KEY_PATH` est l'identité SSH de votre serveur. Tous vos utilisateurs ont ce fingerprint dans leur `~/.ssh/known_hosts`. La régénérer provoque une alerte `REMOTE HOST IDENTIFICATION HAS CHANGED` sur toutes les machines de vos utilisateurs. |
@@ -185,4 +195,6 @@ Le compromis : gitrust gère sa propre implémentation SSH — les mises à jour
| 185 | 195 | |
| 186 | 196 | - [Modèle de déploiement — topologies mono-machine et HA](modele-deploiement.md) |
| 187 | 197 | - [Ports et services — tableau des ports et bindings](../reference/ports-et-services.md) |
| 198 | +- [ssh-guard : détection d'attaques SSH](ssh-guard-detection.md) | |
| 199 | +- [Configurer ssh-guard](../how-to/configurer-ssh-guard.md) | |
| 188 | 200 | - [Sauvegarder et restaurer](../how-to/sauvegarder-restaurer.md) |
A
administration_manual/explanation/ssh-guard-detection.md
+237
-0
@@ -0,0 +1,237 @@
| 1 | +# ssh-guard : détection d'attaques SSH | |
| 2 | + | |
| 3 | +## Ce que vous allez comprendre | |
| 4 | + | |
| 5 | +- Identifier les quatre motifs d'attaque détectés automatiquement et les seuils par défaut. | |
| 6 | +- Analyser comment ssh-guard décide de bannir une IP, dans quel ordre les règles s'appliquent et pourquoi. | |
| 7 | +- Évaluer les compromis (fail-open, dry-run, allowlist bypass) pour ajuster la posture de sécurité de votre instance. | |
| 8 | + | |
| 9 | +> **Public** : administrateurs gitrust qui veulent comprendre **avant** de toucher aux variables `SSH_GUARD_*`. Pour la recette pas à pas, voir [Configurer ssh-guard](../how-to/configurer-ssh-guard.md). | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## 1. Le problème concret | |
| 14 | + | |
| 15 | +Vous administrez une instance gitrust exposée sur Internet. Les logs `russh` remontent des dizaines de tentatives d'authentification ratées chaque jour, sans corrélation. Vous savez intuitivement qu'il y a une attaque par dictionnaire en cours, mais : | |
| 16 | + | |
| 17 | +- vous ne savez pas combien d'IP distinctes la mènent ; | |
| 18 | +- vous ne pouvez pas distinguer un développeur qui a 3 clés mal configurées d'un scanner qui essaie 50 clés en série ; | |
| 19 | +- votre fail2ban actuel se base sur des regex sur les messages d'erreur de `russh`, qui changent à chaque mise à jour ; | |
| 20 | +- vous craignez de bannir un partenaire CI qui ouvre 200 connexions SSH par minute pour ses pipelines. | |
| 21 | + | |
| 22 | +ssh-guard a été conçu exactement pour ce périmètre : observer, classer, corréler, bannir — sans casser les utilisations légitimes intensives. | |
| 23 | + | |
| 24 | +--- | |
| 25 | + | |
| 26 | +## 2. L'analogie | |
| 27 | + | |
| 28 | +Imaginez le hall d'entrée d'un immeuble. Quatre comportements doivent déclencher un signal pour le gardien : | |
| 29 | + | |
| 30 | +1. **Quelqu'un sonne 5 fois en 5 minutes au même appartement** → c'est une tentative de force brute. *(brute-force)* | |
| 31 | +2. **Quelqu'un sonne aux 10 appartements différents en 5 minutes** → il cherche qui est chez lui pour cibler ensuite. *(énumération d'utilisateurs)* | |
| 32 | +3. **Quelqu'un présente 10 cartes magnétiques différentes en 5 minutes** → il essaie un trousseau volé. *(scanning de clés)* | |
| 33 | +4. **Quelqu'un ouvre la porte 30 fois en 1 seconde** → il sature le système, peu importe ses intentions. *(flood TCP)* | |
| 34 | + | |
| 35 | +ssh-guard distingue les quatre, parce qu'ils appellent des réponses différentes. Le flood est un drop instantané (sinon le système se noie). Les trois autres déclenchent un ban temporaire après un nombre d'essais avéré. | |
| 36 | + | |
| 37 | +--- | |
| 38 | + | |
| 39 | +## 3. Les quatre détecteurs | |
| 40 | + | |
| 41 | +### 3.1 Vue synthétique | |
| 42 | + | |
| 43 | +| Détecteur | Mesure | Seuil défaut | Fenêtre | Action | | |
| 44 | +|---|---|---|---|---| | |
| 45 | +| **Brute-force** | Échecs d'auth depuis une IP | 5 | 5 min | Ban 1 h | | |
| 46 | +| **Énumération d'utilisateurs** | Usernames distincts essayés depuis une IP | 10 | 5 min | Ban 1 h | | |
| 47 | +| **Scan de clés** | Fingerprints distincts essayés depuis une IP | 10 | 5 min | Ban 1 h | | |
| 48 | +| **Flood TCP** | Nouvelles connexions TCP par IP par seconde | 10 (burst 20) | 1 s glissante | Drop immédiat (pas de ban persistant) | | |
| 49 | + | |
| 50 | +Tous les seuils sont ajustables via `SSH_GUARD_*` — voir [Variables d'environnement](../reference/variables-environnement.md#22-durcissement-ssh-ssh_guard_). | |
| 51 | + | |
| 52 | +### 3.2 Brute-force | |
| 53 | + | |
| 54 | +Compte les événements `auth_failed` par IP dans la fenêtre glissante. Au seuil, émet `brute_force_detected` puis pose un ban auto avec TTL `SSH_GUARD_AUTO_BAN_DURATION_SECS`. Ne **compte pas** les `auth_succeeded` (c'est l'objectif : ignorer le bruit légitime). | |
| 55 | + | |
| 56 | +### 3.3 Énumération d'utilisateurs | |
| 57 | + | |
| 58 | +Compte les **usernames distincts** essayés par IP. Un attaquant qui sonde `root`, `admin`, `git`, `deploy`, `ci`, `bot`, … pour trouver un compte existant déclenche ce détecteur même si chacune de ses tentatives n'est faite qu'une fois. | |
| 59 | + | |
| 60 | +Cas typiques de faux positif : un script CI qui clone plusieurs dépôts d'orgs différentes avec différents `git@host:org/...` — mais le `user` SSH reste `git`, donc le détecteur ne s'active pas. Un développeur qui se trompe trois fois de username en jouant avec son `~/.ssh/config` reste largement sous le seuil. | |
| 61 | + | |
| 62 | +### 3.4 Scan de clés | |
| 63 | + | |
| 64 | +Compte les **fingerprints SHA256 distincts** présentés par IP. Aligné sur les recommandations CrowdSec : un humain n'a jamais 10 clés SSH distinctes en 5 minutes. Un trousseau volé qui défile en automatique, oui. | |
| 65 | + | |
| 66 | +Cas typique d'observation : `ssh-add -L` côté client liste 8 clés, l'agent SSH les présente toutes successivement. Le seuil par défaut (10) absorbe ce cas. | |
| 67 | + | |
| 68 | +### 3.5 Flood TCP | |
| 69 | + | |
| 70 | +Implémenté avec un token bucket GCRA (algorithme de Generic Cell Rate, équivalent leaky bucket sans biais), keyé par IP. Différent des trois autres : | |
| 71 | + | |
| 72 | +- **Pas de ban persistant** : sinon un scan de port créerait des milliers de lignes inutiles dans la DB. | |
| 73 | +- **Drop au niveau du listener TCP** : la connexion ne consomme aucune ressource au-delà du `accept()`. | |
| 74 | +- **Budgets indépendants par IP** : une IP rate-limitée n'affecte pas les autres. | |
| 75 | + | |
| 76 | +C'est la première ligne de défense, peu coûteuse, qui absorbe les scans avant que les détecteurs comportementaux ne soient sollicités. | |
| 77 | + | |
| 78 | +--- | |
| 79 | + | |
| 80 | +## 4. Le modèle de décision | |
| 81 | + | |
| 82 | +### 4.1 Priorité des règles d'ACL | |
| 83 | + | |
| 84 | +Quand une connexion arrive, ssh-guard consulte dans cet ordre : | |
| 85 | + | |
| 86 | +```mermaid | |
| 87 | +flowchart LR | |
| 88 | + A[Connexion] --> B{Denylist admin ?} | |
| 89 | + B -- Oui --> X1[Drop banned] | |
| 90 | + B -- Non --> C{Ban auto actif ?} | |
| 91 | + C -- Oui --> X1 | |
| 92 | + C -- Non --> D{Allowlist admin ?} | |
| 93 | + D -- Oui --> Y[Accept,<br/>bypass détecteurs] | |
| 94 | + D -- Non --> E{Flood OK ?} | |
| 95 | + E -- Non --> X2[Drop flood_limit] | |
| 96 | + E -- Oui --> Z[Accept normal] | |
| 97 | +``` | |
| 98 | + | |
| 99 | +Conséquences pratiques : | |
| 100 | + | |
| 101 | +- **`deny` admin > tout** : un opérateur peut bannir une IP même si elle a été allowlistée par erreur ailleurs. | |
| 102 | +- **`auto_ban` > `allow`** : un ban posé par un détecteur ne saute pas en allowlistant l'IP après coup. Pour libérer l'IP, il faut explicitement la débannir. C'est volontaire — un `allow` ne doit pas effacer la trace d'un comportement passé. | |
| 103 | +- **`allow` > `default`** : une IP allowlistée bypasse les détecteurs et le flood-limit, mais ses événements `auth_succeeded`/`auth_failed` restent loggés pour audit. | |
| 104 | + | |
| 105 | +### 4.2 Cycle de vie d'un ban brute-force | |
| 106 | + | |
| 107 | +```mermaid | |
| 108 | +sequenceDiagram | |
| 109 | + participant C as Client SSH (203.0.113.42) | |
| 110 | + participant L as SecureListener | |
| 111 | + participant T as AuthTracker | |
| 112 | + participant D as BruteForceDetector | |
| 113 | + participant B as BanManager | |
| 114 | + participant S as Sink JSON | |
| 115 | + participant F as Fail2ban | |
| 116 | + | |
| 117 | + Note over C,F: Tentatives 1 à 4 (sous le seuil) | |
| 118 | + C->>L: TCP accept | |
| 119 | + L->>S: connection_accepted | |
| 120 | + C->>T: auth fail (russh) | |
| 121 | + T->>S: auth_failed | |
| 122 | + T->>D: on_event(auth_failed) | |
| 123 | + D->>D: count = 4 < seuil 5 | |
| 124 | + Note over D: Aucun ban | |
| 125 | + | |
| 126 | + Note over C,F: Tentative 5 (atteint le seuil) | |
| 127 | + C->>T: auth fail (russh) | |
| 128 | + T->>S: auth_failed | |
| 129 | + T->>D: on_event(auth_failed) | |
| 130 | + D->>D: count = 5 >= seuil | |
| 131 | + D->>S: brute_force_detected | |
| 132 | + D->>B: auto_ban(BruteForce) | |
| 133 | + B->>S: ip_banned (expires_at = now + 1h) | |
| 134 | + F->>F: Lit ip_banned, applique iptables/UFW | |
| 135 | + | |
| 136 | + Note over C,F: Tentative 6 (bannie) | |
| 137 | + C->>L: TCP accept | |
| 138 | + L->>B: effective_status(203.0.113.42) | |
| 139 | + B-->>L: BannedAuto | |
| 140 | + L->>S: connection_dropped (banned) | |
| 141 | + L-->>C: connexion fermée | |
| 142 | +``` | |
| 143 | + | |
| 144 | +### 4.3 Idempotence | |
| 145 | + | |
| 146 | +Un détecteur ne **rebannit jamais** une IP déjà bannie. Le `BanManager.auto_ban` retourne `Ok(None)` dans trois cas : | |
| 147 | + | |
| 148 | +- `dry_run` actif (un événement `ip_banned` est tout de même émis pour fail2ban) ; | |
| 149 | +- l'IP est allowlistée ; | |
| 150 | +- un ban auto actif couvre déjà l'IP. | |
| 151 | + | |
| 152 | +Sans cette idempotence, chaque nouvelle tentative ratée après le ban produirait un nouvel `ip_banned` et noierait le sink. | |
| 153 | + | |
| 154 | +--- | |
| 155 | + | |
| 156 | +## 5. Les leviers admin | |
| 157 | + | |
| 158 | +### 5.1 Modes opérationnels | |
| 159 | + | |
| 160 | +| Variable | Effet | | |
| 161 | +|---|---| | |
| 162 | +| `SSH_GUARD_ENABLED=false` | Coupe totale. ssh-guard devient un pass-through. **À éviter sauf urgence.** | | |
| 163 | +| `SSH_GUARD_DRY_RUN=true` | Les détecteurs tournent et émettent les événements `ip_banned`, mais aucun ban n'est persisté. Mode parfait pour valider un nouveau seuil ou laisser fail2ban faire le ban réel. | | |
| 164 | +| `SSH_GUARD_PROFILE=private` | Profil réseau interne : tous les détecteurs désactivés (seuil `u32::MAX`), les événements continuent d'être émis pour audit. | | |
| 165 | + | |
| 166 | +### 5.2 Allowlist d'un partenaire CI ou d'un VPN | |
| 167 | + | |
| 168 | +Une IP allowlistée bypasse les quatre détecteurs et le flood-limit. C'est le levier propre pour autoriser : | |
| 169 | + | |
| 170 | +- un runner CI qui ouvre des dizaines de connexions SSH par minute ; | |
| 171 | +- un bastion VPN qui multiplexe plusieurs développeurs vers une seule IP source ; | |
| 172 | +- un job de monitoring qui teste la disponibilité SSH. | |
| 173 | + | |
| 174 | +Configuré via l'API admin (table `ssh_guard_acl`, kind = `Allow`, CIDR du runner). Voir [Configurer ssh-guard](../how-to/configurer-ssh-guard.md). | |
| 175 | + | |
| 176 | +### 5.3 Denylist d'un AS abusif | |
| 177 | + | |
| 178 | +Un opérateur peut bannir un CIDR entier (ex. `198.51.100.0/24`) en mode permanent. C'est le pendant inverse de l'allowlist, prioritaire sur tout (y compris les bans auto qui n'auraient pas encore été posés). | |
| 179 | + | |
| 180 | +--- | |
| 181 | + | |
| 182 | +## 6. Pourquoi ces compromis | |
| 183 | + | |
| 184 | +### 6.1 Fail-open quand PostgreSQL est indisponible | |
| 185 | + | |
| 186 | +Si la lecture du store échoue (DB down, latence anormale), ssh-guard **laisse passer** la connexion et log un warn. Raison : éviter de transformer une panne DB en panne SSH généralisée. Conséquence acceptée : pendant la fenêtre de panne, un attaquant peut atteindre le handshake `russh` (mais doit toujours présenter une clé valide pour s'authentifier). | |
| 187 | + | |
| 188 | +Pour basculer en fail-closed, il faut modifier le code (voir page développeur). C'est rare dans la vraie vie ; la plupart des admins préfèrent la disponibilité. | |
| 189 | + | |
| 190 | +### 6.2 Bans temporaires plutôt que permanents | |
| 191 | + | |
| 192 | +Le défaut `SSH_GUARD_AUTO_BAN_DURATION_SECS=3600` (1 heure) est un compromis : | |
| 193 | + | |
| 194 | +- assez long pour ralentir significativement un attaquant patient ; | |
| 195 | +- assez court pour qu'un faux positif (un script mal écrit chez un utilisateur légitime) se résolve sans intervention admin. | |
| 196 | + | |
| 197 | +Mettre `0` rend les bans permanents — à n'utiliser qu'avec un workflow admin pour purger régulièrement la table. | |
| 198 | + | |
| 199 | +### 6.3 Fenêtre glissante de 5 minutes | |
| 200 | + | |
| 201 | +Suffisamment large pour qu'un attaquant ne puisse pas « réinitialiser » le compteur en attendant 30 secondes entre tentatives. Suffisamment courte pour qu'un développeur qui se trompe 4 fois de mot de passe à 8h, puis revient à 14h, ne déclenche pas de ban. | |
| 202 | + | |
| 203 | +### 6.4 Allowlist qui bypasse les détecteurs | |
| 204 | + | |
| 205 | +Une IP allowlistée n'est plus surveillée par les détecteurs. Donc une compromission depuis une IP « de confiance » ne sera pas détectée par ssh-guard. Le compromis est explicite : l'allowlist est un signal de confiance opérationnelle, pas une garantie de sécurité. Si un partenaire CI est compromis, l'attaque sera vue par les logs `auth_succeeded` (qui sont toujours émis), pas par les détecteurs. | |
| 206 | + | |
| 207 | +--- | |
| 208 | + | |
| 209 | +## 7. Quand ssh-guard ne suffit pas | |
| 210 | + | |
| 211 | +ssh-guard est une couche de défense. Elle ne dispense pas de : | |
| 212 | + | |
| 213 | +- un firewall en amont (UFW, iptables) qui ferme les ports inutiles ; | |
| 214 | +- des clés SSH fortes (ed25519, ou RSA 4096+) côté utilisateur ; | |
| 215 | +- une politique de rotation des PAT pour l'API HTTP (gitrust ne sert pas que SSH) ; | |
| 216 | +- un fail2ban pour l'HTTP (login web, API), couvert par d'autres jails — voir [Durcir avec Fail2ban](../how-to/durcir-avec-fail2ban.md). | |
| 217 | + | |
| 218 | +Les quatre motifs d'attaque détectés sont les plus fréquents au niveau SSH, mais ils ne couvrent **pas** : attaques par chronométrie, exfiltration via tunnel, abus de privilèges après auth réussie. Ces vecteurs nécessitent d'autres outils (audit syscalls, EDR, revue de RBAC). | |
| 219 | + | |
| 220 | +--- | |
| 221 | + | |
| 222 | +## 8. Vérifier votre compréhension | |
| 223 | + | |
| 224 | +1. Une IP allowlistée présente 50 fingerprints différents en 30 secondes. Combien d'événements `key_scanning_detected` sont émis ? L'IP est-elle bannie ? | |
| 225 | +2. Vous activez `SSH_GUARD_DRY_RUN=true` puis vous voyez 12 événements `ip_banned` dans la dernière heure. Combien d'IP sont effectivement bloquées par ssh-guard ? Que faut-il pour qu'elles soient bloquées ? | |
| 226 | +3. PostgreSQL est inaccessible pendant 10 minutes. Une attaque brute-force depuis une IP nouvelle démarre à la 5e minute. (a) Le détecteur va-t-il déclencher un ban ? (b) Les événements `auth_failed` apparaissent-ils dans les logs ? | |
| 227 | + | |
| 228 | +--- | |
| 229 | + | |
| 230 | +## 9. Pour aller plus loin | |
| 231 | + | |
| 232 | +- [Configurer ssh-guard](../how-to/configurer-ssh-guard.md) — recettes par profil de déploiement | |
| 233 | +- [Événements ssh-guard (JSON)](../reference/ssh-guard-evenements.md) — schéma stable du flux d'événements | |
| 234 | +- [Variables d'environnement — SSH_GUARD_*](../reference/variables-environnement.md#22-durcissement-ssh-ssh_guard_) | |
| 235 | +- [Durcir avec Fail2ban](../how-to/durcir-avec-fail2ban.md) — chaîner ssh-guard et fail2ban | |
| 236 | +- [Architecture globale](architecture-globale.md) — où ssh-guard se place dans le runtime gitrust | |
| 237 | +- [Conformité ANSSI PA-074](../reference/conformite-anssi-pa074.md) |
A
administration_manual/how-to/configurer-ssh-guard.md
+336
-0
@@ -0,0 +1,336 @@
| 1 | +# Configurer ssh-guard (durcissement SSH) | |
| 2 | + | |
| 3 | +## À qui s'adresse cette page | |
| 4 | + | |
| 5 | +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é). | |
| 6 | + | |
| 7 | +> **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](../explanation/ssh-guard-detection.md). La référence des variables est dans [Variables d'environnement — SSH_GUARD_*](../reference/variables-environnement.md#22-durcissement-ssh-ssh_guard_). | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1. Choisir le profil de déploiement | |
| 12 | + | |
| 13 | +Le profil pré-configure les défauts cohérents avec votre topologie. Une variable unique pilote tout le reste : `SSH_GUARD_PROFILE`. | |
| 14 | + | |
| 15 | +| Profil | Topologie | Quand l'utiliser | | |
| 16 | +|---|---|---| | |
| 17 | +| `direct` | gitrust écoute en `:22` ou `:2222` exposé Internet, sans proxy devant | VPS minimal, démo publique | | |
| 18 | +| `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) | | |
| 19 | +| `haproxy` | HAProxy en frontal SSH avec `send-proxy` ou `send-proxy-v2` | Topologies multi-services | | |
| 20 | +| `private` | Instance interne (VPN, réseau d'entreprise), pas d'attaque externe attendue | Intranet, lab | | |
| 21 | +| `custom` | Aucun preset, vous fixez chaque variable individuellement | Cas exotiques | | |
| 22 | + | |
| 23 | +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é. | |
| 24 | + | |
| 25 | +--- | |
| 26 | + | |
| 27 | +## 2. Recette : profil `direct` (Internet direct) | |
| 28 | + | |
| 29 | +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. | |
| 30 | + | |
| 31 | +### 2.1 `.env` minimal | |
| 32 | + | |
| 33 | +Copiez [`template/env/ssh-guard-direct.env`](../../template/env/ssh-guard-direct.env) dans votre `/opt/gitrust/.env` ou ajoutez : | |
| 34 | + | |
| 35 | +```bash | |
| 36 | +SSH_GUARD_ENABLED=true | |
| 37 | +SSH_GUARD_DRY_RUN=false | |
| 38 | +SSH_GUARD_PROFILE=direct | |
| 39 | + | |
| 40 | +# Stockage hybride (RAM chaude + write-through PostgreSQL) | |
| 41 | +SSH_GUARD_STORE_BACKEND=hybrid | |
| 42 | + | |
| 43 | +# Logs JSON dans journald + fichier dédié pour fail2ban | |
| 44 | +SSH_GUARD_LOG_FORMAT=json | |
| 45 | +SSH_GUARD_LOG_TARGET=both | |
| 46 | +SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 47 | +``` | |
| 48 | + | |
| 49 | +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. | |
| 50 | + | |
| 51 | +### 2.2 Redémarrage et vérification | |
| 52 | + | |
| 53 | +```bash | |
| 54 | +sudo systemctl restart gitrust | |
| 55 | + | |
| 56 | +# Le module annonce sa configuration au démarrage | |
| 57 | +sudo journalctl -u gitrust --since "1 min ago" | grep "SSH guard runtime assembly" | |
| 58 | +# Attendu : profile=direct enabled=true dry_run=false store=Hybrid | |
| 59 | + | |
| 60 | +# Logrotate sur le fichier dédié | |
| 61 | +sudo tee /etc/logrotate.d/gitrust-ssh-guard <<'EOF' | |
| 62 | +/var/log/gitrust-ssh-guard.json { | |
| 63 | + daily | |
| 64 | + rotate 14 | |
| 65 | + missingok | |
| 66 | + notifempty | |
| 67 | + compress | |
| 68 | + delaycompress | |
| 69 | + copytruncate | |
| 70 | +} | |
| 71 | +EOF | |
| 72 | +``` | |
| 73 | + | |
| 74 | +--- | |
| 75 | + | |
| 76 | +## 3. Recette : profil `nginx` (derrière nginx stream) | |
| 77 | + | |
| 78 | +Cas typique : nginx termine TLS sur `:443`, fait du SSH stream sur `:22` → `127.0.0.1:2222` avec PROXY protocol v2. | |
| 79 | + | |
| 80 | +### 3.1 nginx (rappel — voir aussi [Durcir avec Fail2ban](durcir-avec-fail2ban.md)) | |
| 81 | + | |
| 82 | +```nginx | |
| 83 | +# /etc/nginx/nginx.conf (bloc stream) | |
| 84 | +stream { | |
| 85 | + upstream gitrust_ssh { | |
| 86 | + server 127.0.0.1:2222; | |
| 87 | + } | |
| 88 | + | |
| 89 | + server { | |
| 90 | + listen 22; | |
| 91 | + proxy_pass gitrust_ssh; | |
| 92 | + proxy_protocol on; # IMPORTANT — sinon ssh-guard rejettera | |
| 93 | + proxy_timeout 30m; # git push de gros pack peut être long | |
| 94 | + } | |
| 95 | +} | |
| 96 | +``` | |
| 97 | + | |
| 98 | +### 3.2 `.env` côté gitrust | |
| 99 | + | |
| 100 | +Copiez [`template/env/ssh-guard-nginx.env`](../../template/env/ssh-guard-nginx.env) ou : | |
| 101 | + | |
| 102 | +```bash | |
| 103 | +SSH_GUARD_ENABLED=true | |
| 104 | +SSH_GUARD_PROFILE=nginx | |
| 105 | + | |
| 106 | +# Le profil nginx pose déjà : | |
| 107 | +# SSH_GUARD_PROXY_PROTOCOL=v2 | |
| 108 | +# SSH_GUARD_PROXY_PROTOCOL_STRICT=true | |
| 109 | +# SSH_GUARD_TRUSTED_PROXIES=127.0.0.1/32,::1/128 | |
| 110 | +# (override individuel possible) | |
| 111 | + | |
| 112 | +SSH_GUARD_LOG_FORMAT=json | |
| 113 | +SSH_GUARD_LOG_TARGET=both | |
| 114 | +SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 115 | +``` | |
| 116 | + | |
| 117 | +Côté binding gitrust : | |
| 118 | + | |
| 119 | +```bash | |
| 120 | +SSH_LISTEN_ADDR=127.0.0.1 | |
| 121 | +SSH_PORT=2222 | |
| 122 | +``` | |
| 123 | + | |
| 124 | +### 3.3 Vérification que la vraie IP est bien extraite | |
| 125 | + | |
| 126 | +Faites un `git ls-remote ssh://git@VOTRE_FQDN/owner/repo.git` depuis une IP externe connue, puis : | |
| 127 | + | |
| 128 | +```bash | |
| 129 | +sudo journalctl -u gitrust --since "1 min ago" | grep '"event":"connection_accepted"' | tail -1 | |
| 130 | +# Attendu : "ip":"<VOTRE IP EXTERNE>" et NON "ip":"127.0.0.1" | |
| 131 | +``` | |
| 132 | + | |
| 133 | +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. | |
| 134 | + | |
| 135 | +--- | |
| 136 | + | |
| 137 | +## 4. Recette : profil `haproxy` | |
| 138 | + | |
| 139 | +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 : | |
| 140 | + | |
| 141 | +```bash | |
| 142 | +SSH_GUARD_ENABLED=true | |
| 143 | +SSH_GUARD_PROFILE=haproxy | |
| 144 | +SSH_GUARD_TRUSTED_PROXIES=10.0.0.5/32 # IP de l'haproxy | |
| 145 | + | |
| 146 | +SSH_GUARD_LOG_FORMAT=json | |
| 147 | +SSH_GUARD_LOG_TARGET=both | |
| 148 | +SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 149 | +``` | |
| 150 | + | |
| 151 | +Si `SSH_GUARD_TRUSTED_PROXIES` est vide, gitrust **refuse de démarrer** avec l'erreur : | |
| 152 | + | |
| 153 | +``` | |
| 154 | +SSH_GUARD_TRUSTED_PROXIES required when SSH_GUARD_PROXY_PROTOCOL is enabled | |
| 155 | +``` | |
| 156 | + | |
| 157 | +C'est volontaire : sans cette protection, n'importe qui pourrait forger un en-tête PROXY pour usurper une IP cliente. | |
| 158 | + | |
| 159 | +--- | |
| 160 | + | |
| 161 | +## 5. Recette : profil `private` (réseau interne) | |
| 162 | + | |
| 163 | +Détecteurs désactivés (seuils `u32::MAX`), événements toujours émis pour audit, store en mémoire (rapide, perdu au restart). | |
| 164 | + | |
| 165 | +```bash | |
| 166 | +SSH_GUARD_ENABLED=true | |
| 167 | +SSH_GUARD_PROFILE=private | |
| 168 | + | |
| 169 | +# Tous les détecteurs sont désactivés par le preset. | |
| 170 | +# Les événements connection_accepted / auth_failed / auth_succeeded | |
| 171 | +# restent émis pour audit. | |
| 172 | + | |
| 173 | +SSH_GUARD_LOG_FORMAT=json | |
| 174 | +SSH_GUARD_LOG_TARGET=stderr # journald uniquement, pas de fichier | |
| 175 | +``` | |
| 176 | + | |
| 177 | +--- | |
| 178 | + | |
| 179 | +## 6. Mode `dry-run` : observer avant de bloquer | |
| 180 | + | |
| 181 | +Idéal pour valider un nouveau seuil ou laisser fail2ban faire le ban réel : | |
| 182 | + | |
| 183 | +```bash | |
| 184 | +SSH_GUARD_DRY_RUN=true | |
| 185 | +``` | |
| 186 | + | |
| 187 | +Effets : | |
| 188 | + | |
| 189 | +- Les détecteurs continuent à corréler. | |
| 190 | +- Un événement `ip_banned` est émis pour chaque déclenchement (signal pour fail2ban / Loki). | |
| 191 | +- **Aucun ban n'est persisté** côté ssh-guard. Le `BanManager` reste no-op. | |
| 192 | + | |
| 193 | +C'est le mode recommandé pendant les premiers jours après un changement de seuil agressif. Surveillez : | |
| 194 | + | |
| 195 | +```bash | |
| 196 | +sudo journalctl -u gitrust --since "24 hours ago" --no-pager \ | |
| 197 | + | grep '"event":"ip_banned"' \ | |
| 198 | + | jq -r '.ip + " " + .reason' \ | |
| 199 | + | sort | uniq -c | sort -rn | |
| 200 | +``` | |
| 201 | + | |
| 202 | +--- | |
| 203 | + | |
| 204 | +## 7. Ajuster les seuils | |
| 205 | + | |
| 206 | +### 7.1 Variables disponibles | |
| 207 | + | |
| 208 | +```bash | |
| 209 | +# Brute-force : nombre d'échecs auth depuis une IP dans la fenêtre | |
| 210 | +SSH_GUARD_BRUTE_FORCE_THRESHOLD=5 # défaut 5 | |
| 211 | +SSH_GUARD_BRUTE_FORCE_WINDOW_SECS=300 # défaut 300 (5 min) | |
| 212 | + | |
| 213 | +# Énumération : nombre d'usernames distincts essayés depuis une IP | |
| 214 | +SSH_GUARD_USER_ENUM_THRESHOLD=10 | |
| 215 | +SSH_GUARD_USER_ENUM_WINDOW_SECS=300 | |
| 216 | + | |
| 217 | +# Scan de clés : nombre de fingerprints distincts essayés depuis une IP | |
| 218 | +SSH_GUARD_KEY_SCAN_THRESHOLD=10 | |
| 219 | +SSH_GUARD_KEY_SCAN_WINDOW_SECS=300 | |
| 220 | + | |
| 221 | +# Flood TCP : cap dur de connexions par seconde par IP | |
| 222 | +SSH_GUARD_CONN_FLOOD_PER_SEC=10 | |
| 223 | +SSH_GUARD_CONN_FLOOD_BURST=20 | |
| 224 | + | |
| 225 | +# Durée d'un ban auto. 0 = permanent. | |
| 226 | +SSH_GUARD_AUTO_BAN_DURATION_SECS=3600 # défaut 3600 (1 h) | |
| 227 | +``` | |
| 228 | + | |
| 229 | +### 7.2 Profils suggérés | |
| 230 | + | |
| 231 | +| Posture | Brute-force | Conn flood | TTL ban | | |
| 232 | +|---|---|---|---| | |
| 233 | +| **Stricte** (instance publique très exposée) | 3 / 5 min | 5/s burst 10 | 6 h | | |
| 234 | +| **Standard** (défaut) | 5 / 5 min | 10/s burst 20 | 1 h | | |
| 235 | +| **Tolérante** (équipe interne, partenaires CI) | 10 / 5 min | 20/s burst 50 | 30 min | | |
| 236 | + | |
| 237 | +Désactiver complètement un détecteur : mettre son seuil à `4294967295` (`u32::MAX`). Le préfixe `private` le fait déjà pour les quatre. | |
| 238 | + | |
| 239 | +--- | |
| 240 | + | |
| 241 | +## 8. Allowlist d'un partenaire CI | |
| 242 | + | |
| 243 | +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). | |
| 244 | + | |
| 245 | +```sql | |
| 246 | +-- Allow le runner CI de l'équipe build (CIDR /32) | |
| 247 | +INSERT INTO ssh_guard_acl (id, kind, ip_cidr, reason, created_by, created_at, updated_at) | |
| 248 | +VALUES ( | |
| 249 | + gen_random_uuid(), | |
| 250 | + 'allow', | |
| 251 | + '198.51.100.42/32', | |
| 252 | + 'Runner CI équipe build (ticket OPS-1234)', | |
| 253 | + '<UUID admin>', | |
| 254 | + NOW(), NOW() | |
| 255 | +); | |
| 256 | +``` | |
| 257 | + | |
| 258 | +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 : | |
| 259 | + | |
| 260 | +```bash | |
| 261 | +sudo journalctl -u gitrust --since "5 min ago" --no-pager \ | |
| 262 | + | grep '"ip":"198.51.100.42"' \ | |
| 263 | + | jq -r '.event' | sort | uniq -c | |
| 264 | +# Attendu : auth_failed: 50, brute_force_detected: 0, ip_banned: 0 | |
| 265 | +``` | |
| 266 | + | |
| 267 | +--- | |
| 268 | + | |
| 269 | +## 9. Bannir manuellement un CIDR abusif | |
| 270 | + | |
| 271 | +Pour un AS qui scanne en masse, posez un ban permanent : | |
| 272 | + | |
| 273 | +```sql | |
| 274 | +-- Denylist permanente d'un CIDR | |
| 275 | +INSERT INTO ssh_guard_bans (id, ip_cidr, reason, banned_at, expires_at, auto_banned) | |
| 276 | +VALUES ( | |
| 277 | + gen_random_uuid(), | |
| 278 | + '198.51.100.0/24', | |
| 279 | + 'admin_deny_list', | |
| 280 | + NOW(), | |
| 281 | + NULL, -- NULL = permanent | |
| 282 | + false | |
| 283 | +); | |
| 284 | +``` | |
| 285 | + | |
| 286 | +`expires_at = NULL` rend le ban permanent. Pour un TTL fini : `NOW() + INTERVAL '7 days'`. | |
| 287 | + | |
| 288 | +--- | |
| 289 | + | |
| 290 | +## 10. Troubleshooting | |
| 291 | + | |
| 292 | +### 10.1 Tous les `ip` valent `127.0.0.1` | |
| 293 | + | |
| 294 | +Vous êtes en profil `direct` mais nginx fait du stream devant gitrust. Passez en `nginx` et activez `proxy_protocol on;` dans nginx. | |
| 295 | + | |
| 296 | +### 10.2 « SSH_GUARD_TRUSTED_PROXIES required » | |
| 297 | + | |
| 298 | +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. | |
| 299 | + | |
| 300 | +### 10.3 Faux positifs sur un développeur avec 8 clés SSH | |
| 301 | + | |
| 302 | +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 : | |
| 303 | + | |
| 304 | +1. Côté dev : forcer une clé spécifique avec `IdentitiesOnly yes` dans `~/.ssh/config`. | |
| 305 | +2. Côté admin : monter le seuil à 15 (`SSH_GUARD_KEY_SCAN_THRESHOLD=15`). | |
| 306 | + | |
| 307 | +### 10.4 Lever un ban auto sans attendre l'expiration | |
| 308 | + | |
| 309 | +```sql | |
| 310 | +UPDATE ssh_guard_bans | |
| 311 | +SET unbanned_at = NOW(), unbanned_by = '<UUID admin>' | |
| 312 | +WHERE id = '<UUID du ban>'; | |
| 313 | +``` | |
| 314 | + | |
| 315 | +ssh-guard émet un `ip_unbanned` au prochain cycle d'`effective_status`. | |
| 316 | + | |
| 317 | +### 10.5 Désactiver temporairement ssh-guard | |
| 318 | + | |
| 319 | +```bash | |
| 320 | +# .env | |
| 321 | +SSH_GUARD_ENABLED=false | |
| 322 | +sudo systemctl restart gitrust | |
| 323 | +``` | |
| 324 | + | |
| 325 | +ssh-guard devient un pass-through complet : aucune détection, aucun ban, le listener délègue directement à `russh`. **À éviter sauf urgence**. | |
| 326 | + | |
| 327 | +--- | |
| 328 | + | |
| 329 | +## 11. Pour aller plus loin | |
| 330 | + | |
| 331 | +- [Variables d'environnement — SSH_GUARD_*](../reference/variables-environnement.md#22-durcissement-ssh-ssh_guard_) | |
| 332 | +- [Événements ssh-guard (JSON)](../reference/ssh-guard-evenements.md) | |
| 333 | +- [ssh-guard : détection d'attaques SSH](../explanation/ssh-guard-detection.md) | |
| 334 | +- [Durcir avec Fail2ban](durcir-avec-fail2ban.md) — chaîner ssh-guard et fail2ban | |
| 335 | +- [Dépanner SSH](depanner-ssh.md) — diagnostic général SSH | |
| 336 | +- [Régler le rate limiting](tuner-rate-limiting.md) — distinction rate-limit HTTP vs TCP |
M
administration_manual/how-to/depanner-ssh.md
+84
-9
@@ -8,22 +8,30 @@ Administrateurs confrontés à des erreurs lors de l'authentification SSH ou des
| 8 | 8 | |
| 9 | 9 | ## Architecture SSH de gitrust |
| 10 | 10 | |
| 11 | -Gitrust intègre son propre serveur SSH via la bibliothèque **Russh** — ce n'est pas le démon `sshd` du système. Il écoute par défaut sur le port **2222**. | |
| 11 | +Gitrust intègre son propre serveur SSH via la bibliothèque **Russh** — ce n'est pas le démon `sshd` du système. Il écoute par défaut sur le port **2222**. Depuis l'introduction de la crate `gitrust-ssh-guard`, **toute connexion TCP** passe d'abord par un sas (`SecureListener`) qui peut la dropper avant le handshake SSH (ban, flood, en-tête PROXY invalide). Voir [Configurer ssh-guard](configurer-ssh-guard.md) et [ssh-guard : détection d'attaques SSH](../explanation/ssh-guard-detection.md). | |
| 12 | 12 | |
| 13 | 13 | ```mermaid |
| 14 | 14 | sequenceDiagram |
| 15 | 15 | participant U as Client git (utilisateur) |
| 16 | + participant G as ssh-guard (SecureListener) | |
| 16 | 17 | participant R as Serveur gitrust (Russh :2222) |
| 17 | 18 | participant DB as PostgreSQL |
| 18 | 19 | |
| 19 | - U->>R: Connexion SSH (port 2222) | |
| 20 | - R->>U: Présente clé hôte Ed25519 | |
| 21 | - U->>U: Vérifie fingerprint (known_hosts) | |
| 22 | - U->>R: Envoie clé publique SSH | |
| 23 | - R->>DB: Cherche fingerprint dans ssh_keys | |
| 24 | - DB-->>R: Trouvé (user_id) | |
| 25 | - R-->>U: Authentifié | |
| 26 | - U->>R: git-upload-pack / git-receive-pack | |
| 20 | + U->>G: Connexion TCP (port 2222) | |
| 21 | + G->>G: ACL / ban / flood | |
| 22 | + alt Drop | |
| 23 | + G-->>U: Connexion fermée | |
| 24 | + else Accept | |
| 25 | + G->>R: TcpStream + ClientIdentity | |
| 26 | + R->>U: Présente clé hôte Ed25519 | |
| 27 | + U->>U: Vérifie fingerprint (known_hosts) | |
| 28 | + U->>R: Envoie clé publique SSH | |
| 29 | + R->>DB: Cherche fingerprint dans ssh_keys | |
| 30 | + DB-->>R: Trouvé (user_id) | |
| 31 | + R->>G: AuthTracker.record_auth_attempt | |
| 32 | + R-->>U: Authentifié | |
| 33 | + U->>R: git-upload-pack / git-receive-pack | |
| 34 | + end | |
| 27 | 35 | ``` |
| 28 | 36 | |
| 29 | 37 | --- |
@@ -174,6 +182,70 @@ ou connexion qui s'établit mais met 30+ secondes.
| 174 | 182 | |
| 175 | 183 | --- |
| 176 | 184 | |
| 185 | +## Erreur 6 : la connexion est droppée par ssh-guard | |
| 186 | + | |
| 187 | +Symptôme côté client : la connexion TCP est acceptée puis fermée immédiatement, sans aucun message d'authentification SSH. Ce comportement est typique d'un drop ssh-guard (ban actif, flood, en-tête PROXY refusé). | |
| 188 | + | |
| 189 | +**Diagnostic** : | |
| 190 | + | |
| 191 | +```bash | |
| 192 | +# Si SSH_GUARD_LOG_TARGET=both ou file | |
| 193 | +sudo tail -100 /var/log/gitrust-ssh-guard.json \ | |
| 194 | + | jq 'select(.event=="connection_dropped")' | |
| 195 | + | |
| 196 | +# Sinon (target=stderr) — passer par journald | |
| 197 | +sudo journalctl -u gitrust --since "5 min ago" --no-pager \ | |
| 198 | + | grep '"event":"connection_dropped"' | tail -10 | |
| 199 | +``` | |
| 200 | + | |
| 201 | +| `reason` retourné | Cause | Correction | | |
| 202 | +|---|---|---| | |
| 203 | +| `banned` | IP couverte par un ban actif (auto ou denylist admin) | Lever le ban via la table `ssh_guard_bans` ou attendre l'expiration. Voir [Configurer ssh-guard §10.4](configurer-ssh-guard.md#104-lever-un-ban-auto-sans-attendre-lexpiration). | | |
| 204 | +| `flood_limit` | Trop de connexions/sec depuis cette IP | Allowlister l'IP si c'est un partenaire CI, ou monter `SSH_GUARD_CONN_FLOOD_PER_SEC`. | | |
| 205 | +| `untrusted_proxy` | En-tête PROXY reçu d'une IP non listée | Vérifier `SSH_GUARD_TRUSTED_PROXIES` ; si nginx tourne sur le même hôte, le défaut `127.0.0.1/32,::1/128` du profil `nginx` doit suffire. | | |
| 206 | +| `proxy_header_invalid` ou `proxy_header_missing` | nginx/HAProxy n'envoie pas le header attendu | Vérifier `proxy_protocol on;` dans nginx (ou `send-proxy-v2` dans HAProxy). En transition, basculer temporairement `SSH_GUARD_PROXY_PROTOCOL_STRICT=false`. | | |
| 207 | + | |
| 208 | +--- | |
| 209 | + | |
| 210 | +## Diagnostic ssh-guard rapide | |
| 211 | + | |
| 212 | +### Lire le flux d'événements en direct | |
| 213 | + | |
| 214 | +```bash | |
| 215 | +# Dernier événement par IP (ssh-guard log target = stderr/journald) | |
| 216 | +sudo journalctl -u gitrust -f | grep '"event":"' | jq -c '.' | |
| 217 | + | |
| 218 | +# Idem si SSH_GUARD_LOG_TARGET=file ou both | |
| 219 | +sudo tail -f /var/log/gitrust-ssh-guard.json | jq -c '.' | |
| 220 | +``` | |
| 221 | + | |
| 222 | +### Mode dry-run pour reproduire un blocage sans persister | |
| 223 | + | |
| 224 | +Si vous suspectez qu'un seuil est trop agressif, passez `SSH_GUARD_DRY_RUN=true` dans `.env` puis redémarrez. Les détecteurs continuent à émettre les `ip_banned` (signal pour fail2ban / observabilité) **sans** que ssh-guard pose réellement le ban. Vous pouvez observer le motif sur 24-48 h avant de décider. | |
| 225 | + | |
| 226 | +### Vérifier que la vraie IP est bien extraite (profils nginx/haproxy) | |
| 227 | + | |
| 228 | +```bash | |
| 229 | +# Faire un git ls-remote depuis une IP externe connue, puis : | |
| 230 | +sudo journalctl -u gitrust --since "1 min ago" --no-pager \ | |
| 231 | + | grep '"event":"connection_accepted"' | tail -1 | jq .ip | |
| 232 | +# Doit retourner l'IP externe, PAS 127.0.0.1 | |
| 233 | +``` | |
| 234 | + | |
| 235 | +### Désactiver temporairement ssh-guard | |
| 236 | + | |
| 237 | +```bash | |
| 238 | +# .env | |
| 239 | +SSH_GUARD_ENABLED=false | |
| 240 | +sudo systemctl restart gitrust | |
| 241 | +``` | |
| 242 | + | |
| 243 | +ssh-guard devient un pass-through complet (aucune détection, aucun ban). À utiliser **seulement** pour confirmer qu'un problème vient bien de la couche guard ; remettre `true` immédiatement après. | |
| 244 | + | |
| 245 | +Pour le détail des événements, voir [Événements ssh-guard (JSON)](../reference/ssh-guard-evenements.md). | |
| 246 | + | |
| 247 | +--- | |
| 248 | + | |
| 177 | 249 | ## Consulter les logs SSH |
| 178 | 250 | |
| 179 | 251 | ```bash |
@@ -190,5 +262,8 @@ sudo journalctl -u gitrust --since "1 hour ago" --no-pager \
| 190 | 262 | ## Pour aller plus loin |
| 191 | 263 | |
| 192 | 264 | - [Variables d'environnement — section SSH](../reference/variables-environnement.md) |
| 265 | +- [Variables d'environnement — section SSH_GUARD_*](../reference/variables-environnement.md#22-durcissement-ssh-ssh_guard_) | |
| 266 | +- [Configurer ssh-guard](configurer-ssh-guard.md) | |
| 267 | +- [Événements ssh-guard (JSON)](../reference/ssh-guard-evenements.md) | |
| 193 | 268 | - [Ports et services](../reference/ports-et-services.md) |
| 194 | 269 | - [Gérer les utilisateurs : révoquer une clé SSH](gerer-utilisateurs-admin.md) |
M
administration_manual/how-to/durcir-avec-fail2ban.md
+50
-24
@@ -1,5 +1,7 @@
| 1 | 1 | # Durcir l'instance avec Fail2ban |
| 2 | 2 | |
| 3 | +> **Évolution importante** : depuis l'introduction de la crate `gitrust-ssh-guard`, le jail `[gitrust-ssh]` ne lit plus les logs `russh` via `journalctl` mais consomme directement le **flux JSON stable** émis par ssh-guard dans `/var/log/gitrust-ssh-guard.json`. Cela rend le jail bien plus fiable (format garanti stable, pas de regex fragile sur les messages de `russh`). Activez `SSH_GUARD_LOG_TARGET=both` dans `.env` — voir [Configurer ssh-guard](configurer-ssh-guard.md). | |
| 4 | + | |
| 3 | 5 | Configuration complète pour protéger un déploiement gitrust exposant : |
| 4 | 6 | |
| 5 | 7 | - **sshd système** sur `:2022` (admin) |
@@ -141,17 +143,25 @@ findtime = 1m
| 141 | 143 | bantime = 2h |
| 142 | 144 | |
| 143 | 145 | # ============================================================================= |
| 144 | -# 8) Gitrust SSH (russh) — brute force clé publique | |
| 146 | +# 8) Gitrust SSH — consomme les événements JSON stables de ssh-guard | |
| 145 | 147 | # ============================================================================= |
| 148 | +# IMPORTANT : ce jail consomme le flux JSON émis par la crate gitrust-ssh-guard | |
| 149 | +# (champs stables documentés dans ../reference/ssh-guard-evenements.md). | |
| 150 | +# Le ban est déclenché soit sur le "signal fort" ip_banned (ssh-guard a déjà | |
| 151 | +# détecté la brute-force, fail2ban relaye au firewall) — un seul ip_banned | |
| 152 | +# suffit (maxretry=1) — soit sur le brut auth_failed avec le seuil habituel. | |
| 153 | +# | |
| 154 | +# Prérequis dans /opt/gitrust/.env : | |
| 155 | +# SSH_GUARD_LOG_TARGET=both | |
| 156 | +# SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 146 | 157 | [gitrust-ssh] |
| 147 | -enabled = true | |
| 148 | -port = 22,2222 | |
| 149 | -filter = gitrust-ssh | |
| 150 | -backend = systemd | |
| 151 | -journalmatch = _SYSTEMD_UNIT=gitrust.service | |
| 152 | -maxretry = 5 | |
| 153 | -findtime = 10m | |
| 154 | -bantime = 1h | |
| 158 | +enabled = true | |
| 159 | +port = 22,2222 | |
| 160 | +filter = gitrust-ssh | |
| 161 | +logpath = /var/log/gitrust-ssh-guard.json | |
| 162 | +maxretry = 1 # ssh-guard a déjà corrélé : 1 ip_banned = 1 ban firewall | |
| 163 | +findtime = 10m | |
| 164 | +bantime = 1h | |
| 155 | 165 | |
| 156 | 166 | # ============================================================================= |
| 157 | 167 | # 9) Gitrust import worker — tokens invalides sur clone de dépôts externes |
@@ -246,24 +256,40 @@ ignoreregex =
| 246 | 256 | EOF |
| 247 | 257 | ``` |
| 248 | 258 | |
| 249 | -### 3.3 `gitrust-ssh.conf` | |
| 259 | +### 3.3 `gitrust-ssh.conf` (consomme le JSON ssh-guard) | |
| 260 | + | |
| 261 | +Le filtre matche les événements JSON stables produits par `gitrust-ssh-guard`. Voir [Événements ssh-guard (JSON)](../reference/ssh-guard-evenements.md) pour le schéma complet. | |
| 262 | + | |
| 263 | +Le « signal fort » `ip_banned` est privilégié : ssh-guard a déjà corrélé brute-force / énumération / scan de clés, et fail2ban n'a plus qu'à appliquer le ban au niveau firewall (UFW/iptables) pour les autres ports si désiré. | |
| 250 | 264 | |
| 251 | 265 | ```bash |
| 252 | 266 | sudo tee /etc/fail2ban/filter.d/gitrust-ssh.conf <<'EOF' |
| 253 | 267 | [Definition] |
| 254 | -# russh / gitrust-ssh : échecs d'authentification | |
| 255 | -# Regex à raffiner après observation des logs réels via : | |
| 256 | -# sudo fail2ban-regex <(journalctl -u gitrust --no-pager -n 2000) \ | |
| 268 | +# Filtre les événements stables émis par gitrust-ssh-guard dans | |
| 269 | +# /var/log/gitrust-ssh-guard.json. Format : une ligne JSON par événement. | |
| 270 | +# | |
| 271 | +# Capture <HOST> depuis le champ "ip" du JSON pour les variants pertinents. | |
| 272 | +# | |
| 273 | +# Tester : | |
| 274 | +# sudo fail2ban-regex /var/log/gitrust-ssh-guard.json \ | |
| 257 | 275 | # /etc/fail2ban/filter.d/gitrust-ssh.conf |
| 258 | -failregex = ^.*ssh.*auth(entication)? (failed|failure).*from <HOST>.*$ | |
| 259 | - ^.*[Rr]ejected.*(public[_ ]?key|password).*from <HOST>.*$ | |
| 260 | - ^.*no matching (ssh|public) key.*from <HOST>.*$ | |
| 261 | - ^.*unknown user.*from <HOST>.*$ | |
| 262 | - ^.*gitrust.*auth.*refused.*<HOST>.*$ | |
| 276 | + | |
| 277 | +failregex = ^.*"event":"ip_banned".*"ip":"<HOST>".*$ | |
| 278 | + ^.*"event":"brute_force_detected".*"ip":"<HOST>".*$ | |
| 279 | + ^.*"event":"user_enumeration_detected".*"ip":"<HOST>".*$ | |
| 280 | + ^.*"event":"key_scanning_detected".*"ip":"<HOST>".*$ | |
| 281 | + ^.*"event":"connection_dropped".*"ip":"<HOST>".*"reason":"untrusted_proxy".*$ | |
| 282 | + ^.*"event":"connection_dropped".*"ip":"<HOST>".*"reason":"proxy_header_invalid".*$ | |
| 283 | + | |
| 263 | 284 | ignoreregex = |
| 285 | + | |
| 286 | +# Date au format ISO 8601 UTC produit par ssh-guard | |
| 287 | +datepattern = "ts":"%%Y-%%m-%%dT%%H:%%M:%%S | |
| 264 | 288 | EOF |
| 265 | 289 | ``` |
| 266 | 290 | |
| 291 | +> **Variante « brute uniquement »** — si vous préférez que fail2ban corrèle lui-même à partir des `auth_failed` sans dépendre du verdict ssh-guard, remplacez le bloc ci-dessus par `failregex = ^.*"event":"auth_failed".*"ip":"<HOST>".*$` et passez `maxretry = 5` dans le jail. Les deux approches sont valides ; la première est plus rapide à réagir, la seconde est plus indépendante. | |
| 292 | + | |
| 267 | 293 | ### 3.4 `gitrust-import.conf` |
| 268 | 294 | |
| 269 | 295 | ```bash |
@@ -445,11 +471,11 @@ sudo fail2ban-regex /var/log/nginx/gitrust.access.log \
| 445 | 471 | sudo fail2ban-regex /var/log/nginx/gitrust.access.log \ |
| 446 | 472 | /etc/fail2ban/filter.d/gitrust-api-abuse.conf |
| 447 | 473 | |
| 448 | -# 3. gitrust-ssh contre journald | |
| 449 | -journalctl -u gitrust --no-pager -n 5000 > /tmp/gitrust.log | |
| 450 | -sudo fail2ban-regex /tmp/gitrust.log \ | |
| 474 | +# 3. gitrust-ssh contre le flux JSON ssh-guard | |
| 475 | +sudo fail2ban-regex /var/log/gitrust-ssh-guard.json \ | |
| 451 | 476 | /etc/fail2ban/filter.d/gitrust-ssh.conf |
| 452 | -rm /tmp/gitrust.log | |
| 477 | +# Si /var/log/gitrust-ssh-guard.json n'existe pas, vérifiez SSH_GUARD_LOG_TARGET | |
| 478 | +# et SSH_GUARD_LOG_FILE dans /opt/gitrust/.env (voir how-to/configurer-ssh-guard.md). | |
| 453 | 479 | |
| 454 | 480 | # 4. postgresql |
| 455 | 481 | journalctl -u docker --no-pager -n 5000 | grep -i postgres > /tmp/pg.log |
@@ -512,7 +538,7 @@ sudo grep "Ban " /var/log/fail2ban.log | tail -20
| 512 | 538 | | 5 | `nginx-limit-req` | 80,443 | nginx error | 10 | 1h | Flood global | |
| 513 | 539 | | 6 | `gitrust-login` | 80,443 | nginx access | 5 | 1h | Brute force login UI + API | |
| 514 | 540 | | 7 | `gitrust-api-abuse` | 80,443 | nginx access | 30 | 2h | Scrapers API (tokens fuités) | |
| 515 | -| 8 | `gitrust-ssh` | 22,2222 | journald gitrust | 5 | 1h | Brute force clé SSH Git | | |
| 541 | +| 8 | `gitrust-ssh` | 22,2222 | `/var/log/gitrust-ssh-guard.json` (JSON stable ssh-guard) | 1 | 1h | Brute force / scan clés / énumération SSH Git | | |
| 516 | 542 | | 9 | `gitrust-import` | 80,443 | journald gitrust | 3 | 30m | Brute force PAT/OAuth import | |
| 517 | 543 | | 10 | `dtrack-login` | 8080 | nginx access | 5 | 2h | Brute force UI Dep-Track | |
| 518 | 544 | | 11 | `dtrack-api` | 8081 | nginx access | 10 | 1h | Abus clé API Dep-Track | |
@@ -567,5 +593,5 @@ Pour recevoir un mail à chaque ban :
| 567 | 593 | |
| 568 | 594 | - **IPv6** : toutes les regex utilisent `<HOST>` qui matche IPv4 ET IPv6. Vérifier que UFW est configuré pour v6 également. |
| 569 | 595 | - **CDN/Cloudflare devant** : si un CDN est ajouté, `$remote_addr` côté Nginx sera l'IP du CDN — il faut récupérer la vraie IP via `X-Forwarded-For` et propager au logging Nginx. Changer `failregex` en conséquence. Sinon les jails banniront le CDN. |
| 570 | -- **Docker/Podman** : si gitrust passe en conteneur, les logs de `gitrust.service` deviennent `docker.service` ou `podman.service` → mettre à jour `journalmatch` dans les jails 8 et 9. | |
| 596 | +- **Docker/Podman** : si gitrust passe en conteneur, les logs de `gitrust.service` deviennent `docker.service` ou `podman.service` → mettre à jour `journalmatch` dans le jail 9 (`gitrust-import`). Le jail 8 (`gitrust-ssh`) n'est pas affecté car il consomme le fichier JSON ssh-guard, à condition que ce fichier soit monté côté hôte. | |
| 571 | 597 | - **GeoIP** : pour bloquer des pays entiers en amont, ajouter `geo $blocked_country` dans Nginx (module `ngx_http_geoip2_module`) — complémentaire à fail2ban. |
M
administration_manual/how-to/tuner-rate-limiting.md
+23
-0
@@ -111,6 +111,26 @@ Gitrust utilise alors `X-Real-IP` pour identifier l'IP cliente réelle dans ses
| 111 | 111 | |
| 112 | 112 | --- |
| 113 | 113 | |
| 114 | +## Rate-limit TCP par IP (ssh-guard) | |
| 115 | + | |
| 116 | +Les variables `RATE_LIMIT_*` ci-dessus s'appliquent à la **couche HTTP** (login, API, refresh JWT). Pour le **port SSH** (`:2222` par défaut), un mécanisme distinct est fourni par la crate `gitrust-ssh-guard` : un cap dur de connexions TCP/seconde par IP, indépendant de tout traitement applicatif. | |
| 117 | + | |
| 118 | +| Variable | Défaut | Description | | |
| 119 | +|----------|--------|-------------| | |
| 120 | +| `SSH_GUARD_CONN_FLOOD_PER_SEC` | `10` | Cap soutenu de connexions/sec par IP. `0` ou `u32::MAX` = désactivé. | | |
| 121 | +| `SSH_GUARD_CONN_FLOOD_BURST` | `20` | Burst momentané autorisé. | | |
| 122 | + | |
| 123 | +Différences avec le rate-limit HTTP : | |
| 124 | + | |
| 125 | +- Pas de réponse `429` : la connexion TCP est **droppée immédiatement** au listener (avant tout handshake SSH). | |
| 126 | +- Pas de ban persistant en DB : un scan de port n'engendre aucune écriture. | |
| 127 | +- Budgets indépendants par IP via token bucket GCRA (algorithme leaky bucket sans biais). | |
| 128 | +- Une IP allowlistée dans `ssh_guard_acl` bypasse complètement ce cap. | |
| 129 | + | |
| 130 | +Pour la recette complète (profils par topologie, dry-run, allowlist CI), voir [Configurer ssh-guard](configurer-ssh-guard.md). Pour le détail des événements émis (`connection_flood_detected`, `connection_dropped` avec `reason=flood_limit`), voir [Événements ssh-guard (JSON)](../reference/ssh-guard-evenements.md). | |
| 131 | + | |
| 132 | +--- | |
| 133 | + | |
| 114 | 134 | ## Fail2ban comme couche complémentaire |
| 115 | 135 | |
| 116 | 136 | Le rate limiting de gitrust agit requête par requête. Fail2ban analyse les logs et peut bannir les IPs pour une durée configurable après plusieurs 429. |
@@ -122,5 +142,8 @@ Voir [Durcir avec Fail2ban](durcir-avec-fail2ban.md) pour la configuration compl
| 122 | 142 | ## Pour aller plus loin |
| 123 | 143 | |
| 124 | 144 | - [Variables d'environnement — section Rate Limiting](../reference/variables-environnement.md) |
| 145 | +- [Variables d'environnement — section SSH_GUARD_*](../reference/variables-environnement.md#22-durcissement-ssh-ssh_guard_) | |
| 146 | +- [Configurer ssh-guard](configurer-ssh-guard.md) | |
| 147 | +- [ssh-guard : détection d'attaques SSH](../explanation/ssh-guard-detection.md) | |
| 125 | 148 | - [Durcir avec Fail2ban](durcir-avec-fail2ban.md) |
| 126 | 149 | - [Conformité ANSSI PA-074 — protection contre les attaques par force brute](../reference/conformite-anssi-pa074.md) |
A
administration_manual/reference/ssh-guard-evenements.md
+266
-0
@@ -0,0 +1,266 @@
| 1 | +# Événements ssh-guard (JSON) | |
| 2 | + | |
| 3 | +Référence stable du flux d'événements émis par la couche `ssh-guard` du serveur SSH gitrust. Ce format est **garanti stable** pour les consommateurs externes (fail2ban, Loki, Vector, SIEM). Tout changement incompatible passe par un nouveau nom d'événement. | |
| 4 | + | |
| 5 | +> **Où trouver ces événements** : selon `SSH_GUARD_LOG_TARGET`, dans le journald du service (`stderr`), dans `/var/log/gitrust-ssh-guard.json` (`file`), ou les deux (`both`). Voir [Variables d'environnement — SSH_GUARD_*](variables-environnement.md#22-durcissement-ssh-ssh_guard_). | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. Forme générale | |
| 10 | + | |
| 11 | +Tous les événements partagent trois champs : | |
| 12 | + | |
| 13 | +| Champ | Type | Description | | |
| 14 | +|---|---|---| | |
| 15 | +| `event` | string | Nom snake_case du type d'événement (clé de filtrage). | | |
| 16 | +| `ts` | string ISO 8601 UTC | Horodatage UTC de l'événement (ex. `2026-04-19T14:32:11.482Z`). | | |
| 17 | +| `ip` | string | Adresse IPv4 ou IPv6 réelle du client (après extraction PROXY si applicable). | | |
| 18 | + | |
| 19 | +Champs additionnels selon le type d'événement (voir tables ci-dessous). | |
| 20 | + | |
| 21 | +--- | |
| 22 | + | |
| 23 | +## 2. Catalogue des événements | |
| 24 | + | |
| 25 | +| `event` | Catégorie | Émis par | Cas d'usage admin | | |
| 26 | +|---|---|---|---| | |
| 27 | +| `connection_accepted` | Trafic | `SecureListener` | Audit volume | | |
| 28 | +| `connection_dropped` | Décision | `SecureListener` ou `ConnectionFloodDetector` | Volumétrie des refus | | |
| 29 | +| `auth_failed` | Authentification | `AuthTracker` | Source principale fail2ban | | |
| 30 | +| `auth_succeeded` | Authentification | `AuthTracker` | Audit accès légitimes | | |
| 31 | +| `brute_force_detected` | Détection | `BruteForceDetector` | Signal fort fail2ban | | |
| 32 | +| `user_enumeration_detected` | Détection | `UserEnumerationDetector` | Signal fort fail2ban | | |
| 33 | +| `key_scanning_detected` | Détection | `KeyScanningDetector` | Signal fort fail2ban | | |
| 34 | +| `connection_flood_detected` | Détection | `ConnectionFloodDetector` | Signal fort fail2ban | | |
| 35 | +| `ip_banned` | Action | `BanManager` | Ban à appliquer côté firewall (fail2ban) | | |
| 36 | +| `ip_unbanned` | Action | `BanManager` | Levée de ban (manuelle ou TTL) | | |
| 37 | + | |
| 38 | +--- | |
| 39 | + | |
| 40 | +## 3. Référence par événement | |
| 41 | + | |
| 42 | +### 3.1 `connection_accepted` | |
| 43 | + | |
| 44 | +Une connexion TCP a passé tous les contrôles ssh-guard. Le `russh` handshake va démarrer. | |
| 45 | + | |
| 46 | +| Champ | Type | Description | | |
| 47 | +|---|---|---| | |
| 48 | +| `event` | `"connection_accepted"` | | | |
| 49 | +| `ts` | string | Horodatage UTC | | |
| 50 | +| `session_id` | string UUID v4 | Identifiant unique de session, présent ensuite dans tous les événements liés (`auth_failed`, `auth_succeeded`) | | |
| 51 | +| `ip` | string | IP cliente réelle | | |
| 52 | + | |
| 53 | +```json | |
| 54 | +{"event":"connection_accepted","ts":"2026-04-19T14:32:11.482Z","session_id":"a4c2b8e1-9f3d-4d7e-8c11-0a5d9b6f4e22","ip":"203.0.113.42"} | |
| 55 | +``` | |
| 56 | + | |
| 57 | +### 3.2 `connection_dropped` | |
| 58 | + | |
| 59 | +ssh-guard a refusé la connexion **avant** le handshake SSH. | |
| 60 | + | |
| 61 | +| Champ | Type | Description | | |
| 62 | +|---|---|---| | |
| 63 | +| `event` | `"connection_dropped"` | | | |
| 64 | +| `ts` | string | Horodatage UTC | | |
| 65 | +| `ip` | string | IP source (réelle si PROXY parsé, sinon `peer_addr`) | | |
| 66 | +| `reason` | enum string | Voir tableau des raisons ci-dessous | | |
| 67 | + | |
| 68 | +Valeurs de `reason` : | |
| 69 | + | |
| 70 | +| `reason` | Signification | | |
| 71 | +|---|---| | |
| 72 | +| `banned` | IP couverte par un ban actif (auto ou denylist admin) | | |
| 73 | +| `flood_limit` | Cap de connexions/seconde par IP atteint | | |
| 74 | +| `proxy_header_missing` | PROXY protocol obligatoire mais en-tête absent (timeout) | | |
| 75 | +| `untrusted_proxy` | En-tête PROXY reçu d'un socket pas dans `trusted_proxies` | | |
| 76 | +| `proxy_header_invalid` | En-tête PROXY malformé ou version non autorisée | | |
| 77 | +| `concurrent_limit` | Limite de sessions concurrentes par IP atteinte (placeholder, non actif) | | |
| 78 | + | |
| 79 | +```json | |
| 80 | +{"event":"connection_dropped","ts":"2026-04-19T14:33:02.117Z","ip":"203.0.113.42","reason":"banned"} | |
| 81 | +``` | |
| 82 | + | |
| 83 | +### 3.3 `auth_failed` | |
| 84 | + | |
| 85 | +Tentative d'authentification SSH refusée. **Source principale pour fail2ban.** | |
| 86 | + | |
| 87 | +| Champ | Type | Description | | |
| 88 | +|---|---|---| | |
| 89 | +| `event` | `"auth_failed"` | | | |
| 90 | +| `ts` | string | Horodatage UTC | | |
| 91 | +| `session_id` | string UUID | Lien avec le `connection_accepted` | | |
| 92 | +| `ip` | string | IP cliente réelle | | |
| 93 | +| `user` | string ou `null` | Nom d'utilisateur tenté (si fourni par le client) | | |
| 94 | +| `method` | enum string | Méthode SSH : `none`, `password`, `public_key`, `keyboard_interactive`, `host_based` | | |
| 95 | +| `fingerprint` | string ou `null` | Fingerprint SHA256 de la clé publique tentée (forme `SHA256:...`) ou `null` si non `public_key` | | |
| 96 | + | |
| 97 | +```json | |
| 98 | +{"event":"auth_failed","ts":"2026-04-19T14:32:13.221Z","session_id":"a4c2b8e1-9f3d-4d7e-8c11-0a5d9b6f4e22","ip":"203.0.113.42","user":"root","method":"public_key","fingerprint":"SHA256:k1Qp9xJ8r6Z3HfV2Bn7tT5Cw"} | |
| 99 | +``` | |
| 100 | + | |
| 101 | +### 3.4 `auth_succeeded` | |
| 102 | + | |
| 103 | +Tentative d'authentification SSH validée. Audit des accès légitimes. | |
| 104 | + | |
| 105 | +| Champ | Type | Description | | |
| 106 | +|---|---|---| | |
| 107 | +| `event` | `"auth_succeeded"` | | | |
| 108 | +| `ts` | string | Horodatage UTC | | |
| 109 | +| `session_id` | string UUID | | | |
| 110 | +| `ip` | string | | | |
| 111 | +| `user` | string | Nom d'utilisateur authentifié | | |
| 112 | +| `fingerprint` | string ou `null` | Fingerprint de la clé utilisée (peut être `null` si méthode sans clé) | | |
| 113 | + | |
| 114 | +```json | |
| 115 | +{"event":"auth_succeeded","ts":"2026-04-19T14:32:14.005Z","session_id":"a4c2b8e1-9f3d-4d7e-8c11-0a5d9b6f4e22","ip":"203.0.113.42","user":"alice","fingerprint":"SHA256:p3Lm7nB6xQz9fK1WyT8c"} | |
| 116 | +``` | |
| 117 | + | |
| 118 | +### 3.5 `brute_force_detected` | |
| 119 | + | |
| 120 | +Le seuil `SSH_GUARD_BRUTE_FORCE_THRESHOLD` a été atteint. Un événement `ip_banned` suit immédiatement. | |
| 121 | + | |
| 122 | +| Champ | Type | Description | | |
| 123 | +|---|---|---| | |
| 124 | +| `event` | `"brute_force_detected"` | | | |
| 125 | +| `ts` | string | Horodatage UTC | | |
| 126 | +| `ip` | string | IP fautive | | |
| 127 | +| `count` | number | Nombre d'`auth_failed` comptés dans la fenêtre | | |
| 128 | +| `window_secs` | number | Largeur de la fenêtre (en secondes) | | |
| 129 | + | |
| 130 | +```json | |
| 131 | +{"event":"brute_force_detected","ts":"2026-04-19T14:36:42.998Z","ip":"203.0.113.42","count":5,"window_secs":300} | |
| 132 | +``` | |
| 133 | + | |
| 134 | +### 3.6 `user_enumeration_detected` | |
| 135 | + | |
| 136 | +Le seuil `SSH_GUARD_USER_ENUM_THRESHOLD` (nombre d'usernames distincts essayés depuis la même IP) a été atteint. | |
| 137 | + | |
| 138 | +| Champ | Type | Description | | |
| 139 | +|---|---|---| | |
| 140 | +| `event` | `"user_enumeration_detected"` | | | |
| 141 | +| `ts` | string | Horodatage UTC | | |
| 142 | +| `ip` | string | | | |
| 143 | +| `distinct_users` | number | Nombre d'usernames distincts dans la fenêtre | | |
| 144 | +| `window_secs` | number | | | |
| 145 | + | |
| 146 | +```json | |
| 147 | +{"event":"user_enumeration_detected","ts":"2026-04-19T14:38:15.402Z","ip":"203.0.113.42","distinct_users":10,"window_secs":300} | |
| 148 | +``` | |
| 149 | + | |
| 150 | +### 3.7 `key_scanning_detected` | |
| 151 | + | |
| 152 | +Le seuil `SSH_GUARD_KEY_SCAN_THRESHOLD` (nombre de fingerprints distincts essayés depuis la même IP) a été atteint. | |
| 153 | + | |
| 154 | +| Champ | Type | Description | | |
| 155 | +|---|---|---| | |
| 156 | +| `event` | `"key_scanning_detected"` | | | |
| 157 | +| `ts` | string | Horodatage UTC | | |
| 158 | +| `ip` | string | | | |
| 159 | +| `distinct_keys` | number | Nombre de fingerprints distincts dans la fenêtre | | |
| 160 | +| `window_secs` | number | | | |
| 161 | + | |
| 162 | +```json | |
| 163 | +{"event":"key_scanning_detected","ts":"2026-04-19T14:39:08.117Z","ip":"203.0.113.42","distinct_keys":10,"window_secs":300} | |
| 164 | +``` | |
| 165 | + | |
| 166 | +### 3.8 `connection_flood_detected` | |
| 167 | + | |
| 168 | +Le cap `SSH_GUARD_CONN_FLOOD_PER_SEC` a été dépassé pour cette IP. Un `connection_dropped` avec `reason="flood_limit"` suit dans le même millième de seconde. | |
| 169 | + | |
| 170 | +| Champ | Type | Description | | |
| 171 | +|---|---|---| | |
| 172 | +| `event` | `"connection_flood_detected"` | | | |
| 173 | +| `ts` | string | Horodatage UTC | | |
| 174 | +| `ip` | string | | | |
| 175 | +| `rate_per_sec` | number | Cap nominal (valeur de `SSH_GUARD_CONN_FLOOD_PER_SEC`) | | |
| 176 | + | |
| 177 | +```json | |
| 178 | +{"event":"connection_flood_detected","ts":"2026-04-19T14:40:01.555Z","ip":"203.0.113.42","rate_per_sec":10} | |
| 179 | +``` | |
| 180 | + | |
| 181 | +### 3.9 `ip_banned` | |
| 182 | + | |
| 183 | +Un ban a été appliqué (auto par un détecteur, manuel par un admin, ou simulé en `dry_run`). | |
| 184 | + | |
| 185 | +| Champ | Type | Description | | |
| 186 | +|---|---|---| | |
| 187 | +| `event` | `"ip_banned"` | | | |
| 188 | +| `ts` | string | Horodatage UTC | | |
| 189 | +| `ip` | string | IP bannie (pour un CIDR > /32, l'adresse réseau) | | |
| 190 | +| `reason` | enum string | Voir tableau ci-dessous | | |
| 191 | +| `expires_at` | string ou `null` | Horodatage UTC d'expiration. `null` = ban permanent | | |
| 192 | + | |
| 193 | +Valeurs de `reason` : | |
| 194 | + | |
| 195 | +| `reason` | Origine | | |
| 196 | +|---|---| | |
| 197 | +| `brute_force` | `BruteForceDetector` | | |
| 198 | +| `user_enumeration` | `UserEnumerationDetector` | | |
| 199 | +| `key_scanning` | `KeyScanningDetector` | | |
| 200 | +| `connection_flood` | `ConnectionFloodDetector` (rare : le flood drop ne pose pas de ban persistant par défaut) | | |
| 201 | +| `admin_deny_list` | Ajout admin dans l'ACL deny | | |
| 202 | +| `manual` | Ban manuel via UI/API admin | | |
| 203 | + | |
| 204 | +```json | |
| 205 | +{"event":"ip_banned","ts":"2026-04-19T14:36:42.999Z","ip":"203.0.113.42","reason":"brute_force","expires_at":"2026-04-19T15:36:42.999Z"} | |
| 206 | +``` | |
| 207 | + | |
| 208 | +> **Mode `dry_run`** : un `ip_banned` est émis pour signal externe (fail2ban) **sans** que ssh-guard inscrive le ban dans son store. Utile pour observer un nouveau seuil sans risque. | |
| 209 | + | |
| 210 | +### 3.10 `ip_unbanned` | |
| 211 | + | |
| 212 | +Un ban a été levé (TTL expiré ou action admin). | |
| 213 | + | |
| 214 | +| Champ | Type | Description | | |
| 215 | +|---|---|---| | |
| 216 | +| `event` | `"ip_unbanned"` | | | |
| 217 | +| `ts` | string | Horodatage UTC | | |
| 218 | +| `ip` | string | | | |
| 219 | +| `manual` | boolean | `true` = action admin, `false` = expiration auto | | |
| 220 | + | |
| 221 | +```json | |
| 222 | +{"event":"ip_unbanned","ts":"2026-04-19T15:36:43.001Z","ip":"203.0.113.42","manual":false} | |
| 223 | +``` | |
| 224 | + | |
| 225 | +--- | |
| 226 | + | |
| 227 | +## 4. Filtrage côté outils externes | |
| 228 | + | |
| 229 | +### 4.1 Fail2ban (filtre par regex sur le fichier JSON) | |
| 230 | + | |
| 231 | +```ini | |
| 232 | +[Definition] | |
| 233 | +# Brute-force détecté → ban dur côté firewall | |
| 234 | +failregex = ^.*"event":"auth_failed".*"ip":"<HOST>".*$ | |
| 235 | +ignoreregex = | |
| 236 | +``` | |
| 237 | + | |
| 238 | +Voir le template clé en main : [`template/security/fail2ban-gitrust-ssh-guard.conf`](../../template/security/fail2ban-gitrust-ssh-guard.conf) et le how-to [Durcir avec Fail2ban](../how-to/durcir-avec-fail2ban.md). | |
| 239 | + | |
| 240 | +### 4.2 Loki (LogQL) | |
| 241 | + | |
| 242 | +```logql | |
| 243 | +# Toutes les détections fortes (signal pour alerting Grafana) | |
| 244 | +{job="gitrust-ssh-guard"} | |
| 245 | + | json | |
| 246 | + | event=~"brute_force_detected|user_enumeration_detected|key_scanning_detected|connection_flood_detected" | |
| 247 | +``` | |
| 248 | + | |
| 249 | +### 4.3 jq (extraction shell) | |
| 250 | + | |
| 251 | +```bash | |
| 252 | +# 10 IP les plus actives en échec d'auth sur la dernière heure | |
| 253 | +sudo journalctl -u gitrust --since "1 hour ago" --no-pager \ | |
| 254 | + | jq -r 'select(.event=="auth_failed") | .ip' \ | |
| 255 | + | sort | uniq -c | sort -rn | head -10 | |
| 256 | +``` | |
| 257 | + | |
| 258 | +--- | |
| 259 | + | |
| 260 | +## 5. Pour aller plus loin | |
| 261 | + | |
| 262 | +- [Variables d'environnement — SSH_GUARD_*](variables-environnement.md#22-durcissement-ssh-ssh_guard_) | |
| 263 | +- [Configurer ssh-guard](../how-to/configurer-ssh-guard.md) | |
| 264 | +- [ssh-guard : détection d'attaques SSH](../explanation/ssh-guard-detection.md) | |
| 265 | +- [Durcir avec Fail2ban](../how-to/durcir-avec-fail2ban.md) | |
| 266 | +- [Crate gitrust-ssh-guard](../../developer_manual/reference/ssh-guard-crate.md) (pour les contributeurs) |
M
administration_manual/reference/variables-environnement.md
+73
-0
@@ -277,6 +277,77 @@ Ces variables ont des valeurs par défaut raisonnables. Ne les ajustez que si vo
| 277 | 277 | |
| 278 | 278 | --- |
| 279 | 279 | |
| 280 | +## 22. Durcissement SSH (`SSH_GUARD_*`) | |
| 281 | + | |
| 282 | +Variables consommées par la crate `gitrust-ssh-guard`. Toutes sont préfixées `SSH_GUARD_`. Pour le détail des comportements, voir [Configurer ssh-guard](../how-to/configurer-ssh-guard.md) et [ssh-guard : détection d'attaques SSH](../explanation/ssh-guard-detection.md). | |
| 283 | + | |
| 284 | +> **Modèle de configuration** : `SSH_GUARD_PROFILE` sélectionne un preset cohérent (`direct` / `nginx` / `haproxy` / `private` / `custom`). Chaque autre variable override individuellement le défaut du preset. | |
| 285 | + | |
| 286 | +### 22.1 Kill switch et profil | |
| 287 | + | |
| 288 | +| Variable | Défaut | Description | | |
| 289 | +|----------|--------|-------------| | |
| 290 | +| `SSH_GUARD_ENABLED` | `true` | `false` = pass-through complet, ssh-guard ne fait rien. **À éviter sauf urgence**. | | |
| 291 | +| `SSH_GUARD_DRY_RUN` | `false` | `true` = les détecteurs tournent et émettent les événements `ip_banned`, mais aucun ban n'est persisté. Idéal pour valider un nouveau seuil. | | |
| 292 | +| `SSH_GUARD_PROFILE` | `custom` | Preset : `direct` (Internet direct), `nginx` (derrière nginx stream + PROXY v2), `haproxy` (derrière HAProxy), `private` (réseau interne, détecteurs OFF), `custom` (aucun preset). | | |
| 293 | + | |
| 294 | +### 22.2 PROXY protocol | |
| 295 | + | |
| 296 | +| Variable | Défaut | Description | | |
| 297 | +|----------|--------|-------------| | |
| 298 | +| `SSH_GUARD_PROXY_PROTOCOL` | `disabled` | Mode parsing PROXY : `disabled`, `v1` (texte HAProxy legacy), `v2` (binaire nginx stream / HAProxy moderne), `any` (auto-détection v1/v2). | | |
| 299 | +| `SSH_GUARD_PROXY_PROTOCOL_STRICT` | `true` | `true` = drop si en-tête PROXY absent ou invalide quand le mode est activé. `false` = fallback sur `peer_addr` avec warn (utile en transition). | | |
| 300 | +| `SSH_GUARD_PROXY_PROTOCOL_TIMEOUT_MS` | `3000` | Timeout (ms) pour lire l'en-tête PROXY après accept TCP. | | |
| 301 | +| `SSH_GUARD_TRUSTED_PROXIES` | — | **Obligatoire si PROXY activé**. Liste CIDR (séparée par virgules) des proxies autorisés à émettre un en-tête PROXY. Sans cette protection, n'importe qui pourrait forger une IP cliente. Exemple : `127.0.0.1/32,::1/128`. | | |
| 302 | + | |
| 303 | +### 22.3 Détecteurs (seuils + fenêtres) | |
| 304 | + | |
| 305 | +| Variable | Défaut | Description | | |
| 306 | +|----------|--------|-------------| | |
| 307 | +| `SSH_GUARD_BRUTE_FORCE_THRESHOLD` | `5` | Nombre d'échecs d'auth depuis une IP avant ban auto. `4294967295` (`u32::MAX`) = détecteur désactivé. | | |
| 308 | +| `SSH_GUARD_BRUTE_FORCE_WINDOW_SECS` | `300` | Fenêtre glissante (secondes). | | |
| 309 | +| `SSH_GUARD_USER_ENUM_THRESHOLD` | `10` | Nombre d'usernames distincts essayés depuis une IP. | | |
| 310 | +| `SSH_GUARD_USER_ENUM_WINDOW_SECS` | `300` | | | |
| 311 | +| `SSH_GUARD_KEY_SCAN_THRESHOLD` | `10` | Nombre de fingerprints de clé distincts essayés depuis une IP. | | |
| 312 | +| `SSH_GUARD_KEY_SCAN_WINDOW_SECS` | `300` | | | |
| 313 | +| `SSH_GUARD_CONN_FLOOD_PER_SEC` | `10` | Cap dur de nouvelles connexions TCP par IP par seconde. `0` ou `u32::MAX` = désactivé. | | |
| 314 | +| `SSH_GUARD_CONN_FLOOD_BURST` | `20` | Burst autorisé au-dessus du cap soutenu. | | |
| 315 | +| `SSH_GUARD_MAX_CONCURRENT_PER_IP` | `10` | Limite de sessions concurrentes par IP (placeholder, non encore appliqué). | | |
| 316 | + | |
| 317 | +### 22.4 Ban | |
| 318 | + | |
| 319 | +| Variable | Défaut | Description | | |
| 320 | +|----------|--------|-------------| | |
| 321 | +| `SSH_GUARD_AUTO_BAN_DURATION_SECS` | `3600` | TTL des bans posés par les détecteurs. `0` = ban permanent. | | |
| 322 | + | |
| 323 | +### 22.5 Stockage | |
| 324 | + | |
| 325 | +| Variable | Défaut | Description | | |
| 326 | +|----------|--------|-------------| | |
| 327 | +| `SSH_GUARD_STORE_BACKEND` | `hybrid` | `memory` (DashMap, perdu au restart), `postgres` (chaque write en DB), `hybrid` (RAM chaude + write-through DB + rehydrate au boot — défaut recommandé). | | |
| 328 | +| `SSH_GUARD_STORE_FLUSH_INTERVAL_MS` | `1000` | Intervalle entre deux flushs RAM → Postgres pour le mode `hybrid`. | | |
| 329 | +| `SSH_GUARD_EVENTS_RETENTION_DAYS` | `90` | Rétention de la table `ssh_guard_events` (utilisée par les détecteurs). Au-delà, les lignes sont purgées. | | |
| 330 | + | |
| 331 | +### 22.6 Observabilité | |
| 332 | + | |
| 333 | +| Variable | Défaut | Description | | |
| 334 | +|----------|--------|-------------| | |
| 335 | +| `SSH_GUARD_LOG_FORMAT` | `json` | `json` (stable, fail2ban-friendly) ou `text` (debug uniquement). | | |
| 336 | +| `SSH_GUARD_LOG_TARGET` | `stderr` | `stderr` (via `tracing` → journald), `file` (fichier dédié uniquement), `both` (les deux). | | |
| 337 | +| `SSH_GUARD_LOG_FILE` | `/var/log/gitrust-ssh-guard.json` | Chemin du fichier dédié si `LOG_TARGET=file` ou `both`. **Logrotate quasi obligatoire** (sinon le fichier grossit sans limite). | | |
| 338 | +| `SSH_GUARD_METRICS_ENABLED` | `true` | Expose les métriques Prometheus (placeholder, exposition future). | | |
| 339 | + | |
| 340 | +### 22.7 Validations bloquantes au démarrage | |
| 341 | + | |
| 342 | +gitrust **refuse de démarrer** si : | |
| 343 | + | |
| 344 | +- `SSH_GUARD_PROXY_PROTOCOL` est activé sans `SSH_GUARD_TRUSTED_PROXIES` (forge d'IP triviale sinon). | |
| 345 | +- `SSH_GUARD_LOG_TARGET=file` ou `both` sans `SSH_GUARD_LOG_FILE` valide. | |
| 346 | + | |
| 347 | +Le message d'erreur identifie la variable fautive. | |
| 348 | + | |
| 349 | +--- | |
| 350 | + | |
| 280 | 351 | ## Récapitulatif local vs production |
| 281 | 352 | |
| 282 | 353 | | Dimension | Local (dev) | Production | |
@@ -301,3 +372,5 @@ Ces variables ont des valeurs par défaut raisonnables. Ne les ajustez que si vo
| 301 | 372 | |
| 302 | 373 | - [Paramètres dynamiques `/admin/settings`](parametres-dynamiques.md) — variables modifiables à chaud sans redémarrage |
| 303 | 374 | - [Tutoriel 02 — Installation systemd](../tutorials/02-installation-systemd.md) — exemple de `.env` minimal de production |
| 375 | +- [Configurer ssh-guard](../how-to/configurer-ssh-guard.md) — recettes par profil de déploiement | |
| 376 | +- [Événements ssh-guard (JSON)](ssh-guard-evenements.md) — schéma des événements émis |
M
book.toml
+1
-2
@@ -11,12 +11,11 @@ preferred-dark-theme = "navy"
| 11 | 11 | site-url = "/" |
| 12 | 12 | # URL canonique du site publié (utilisée par theme/head.hbs pour les balises canonical, |
| 13 | 13 | # OpenGraph, hreflang et JSON-LD). À ajuster avant déploiement final. |
| 14 | -git-repository-url = "https://demo.gitrust.eu/gitrust/girust_doc" | |
| 15 | 14 | # Lien vers le dépôt de la documentation sur l'instance de démo |
| 16 | 15 | # TODO : remplacer par l'URL définitive une fois le dépôt public créé |
| 17 | 16 | edit-url-template = "https://demo.gitrust.eu/gitrust/girust_doc/edit/main/{path}" |
| 18 | 17 | additional-css = ["theme/gitrust-branding.css"] |
| 19 | -additional-js = ["theme/mermaid.min.js", "theme/mermaid-init.js"] | |
| 18 | +additional-js = ["theme/mermaid.min.js", "theme/mermaid-init.js", "theme/back-to-landing.js"] | |
| 20 | 19 | |
| 21 | 20 | [output.html.fold] |
| 22 | 21 | enable = true |
A
developer_manual/explanation/ssh-guard-conception.md
+211
-0
@@ -0,0 +1,211 @@
| 1 | +# Conception de ssh-guard | |
| 2 | + | |
| 3 | +## Ce que vous allez comprendre | |
| 4 | + | |
| 5 | +- 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. | |
| 6 | +- Analyser le design async/event-driven choisi : pourquoi un listener wrapper, pourquoi des détecteurs push-based, pourquoi le store hybride. | |
| 7 | +- Évaluer les compromis (fail-open, dry-run, idempotence, priorité ACL) et savoir comment les ajuster. | |
| 8 | + | |
| 9 | +> **Public** : contributeurs au code de `gitrust-ssh` et `gitrust-ssh-guard`. Pour une recette d'exploitation, voir [Configurer ssh-guard](../../administration_manual/how-to/configurer-ssh-guard.md). | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## 1. Le problème concret | |
| 14 | + | |
| 15 | +Avant ssh-guard, le serveur SSH `russh` intégré à gitrust avait quatre angles morts opérationnels : | |
| 16 | + | |
| 17 | +1. **IP cliente faussée derrière nginx stream.** Le déploiement type (« nginx en frontal sur `:22`, `proxy_protocol on;` vers `127.0.0.1:2222` ») rendait `peer_addr() = 127.0.0.1` côté gitrust. Conséquence : tous les rate-limits par IP étaient inutilisables, les logs n'identifiaient personne, fail2ban était impossible à brancher. | |
| 18 | +2. **Aucune détection de motifs d'attaque.** La couche `russh` rejette une auth invalide, mais ne corrèle rien : 1 000 tentatives en 30 secondes ressemblent à 1 000 lignes de log indépendantes. | |
| 19 | +3. **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). | |
| 20 | +4. **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. | |
| 21 | + | |
| 22 | +ssh-guard adresse exactement ces quatre points — pas plus. Toute la couche TLS, OAuth, JWT, RBAC reste hors de son périmètre. | |
| 23 | + | |
| 24 | +--- | |
| 25 | + | |
| 26 | +## 2. L'analogie | |
| 27 | + | |
| 28 | +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 : | |
| 29 | + | |
| 30 | +1. **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. | |
| 31 | +2. **Consulter une liste blanche/noire** affichée à l'intérieur du sas — c'est l'ACL admin. | |
| 32 | +3. **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. | |
| 33 | + | |
| 34 | +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. | |
| 35 | + | |
| 36 | +--- | |
| 37 | + | |
| 38 | +## 3. Le modèle | |
| 39 | + | |
| 40 | +### Vie d'une connexion | |
| 41 | + | |
| 42 | +```mermaid | |
| 43 | +flowchart TD | |
| 44 | + A[Connexion TCP entrante] --> B[peer_addr du socket] | |
| 45 | + B --> C{proxy_config présent ?} | |
| 46 | + C -- Non, profil direct --> E[ip_réelle = peer_addr] | |
| 47 | + C -- Oui, profil proxifié --> D{peer_addr dans<br/>trusted_proxies ?} | |
| 48 | + D -- Non --> X1[Drop : UntrustedProxy] | |
| 49 | + D -- Oui --> P[Parsing PROXY v1/v2] | |
| 50 | + P -- OK --> E2[ip_réelle = ip_cliente du header] | |
| 51 | + P -- Échec strict --> X2[Drop : ProxyHeaderInvalid/Missing] | |
| 52 | + P -- Échec souple --> E3[fallback peer_addr + warn] | |
| 53 | + | |
| 54 | + E --> F[BanManager.effective_status] | |
| 55 | + E2 --> F | |
| 56 | + E3 --> F | |
| 57 | + | |
| 58 | + F --> G{ACL Deny ?} | |
| 59 | + G -- Oui --> X3[Drop : Banned] | |
| 60 | + G -- Non --> H{Ban auto actif ?} | |
| 61 | + H -- Oui --> X3 | |
| 62 | + H -- Non --> I{ACL Allow ?} | |
| 63 | + I -- Oui --> J[Bypass flood,<br/>Accept] | |
| 64 | + I -- Non --> K{Flood actif ?} | |
| 65 | + K -- Oui : check_key OK --> L[Accept] | |
| 66 | + K -- Oui : refusé --> X4[Drop : FloodLimit] | |
| 67 | + K -- Non --> L | |
| 68 | + | |
| 69 | + L --> M[ClientIdentity créé,<br/>handshake russh] | |
| 70 | + M --> N[Tentatives auth] | |
| 71 | + N --> O[AuthTracker.record_auth_attempt] | |
| 72 | + O --> O1[Persiste dans store] | |
| 73 | + O --> O2[Émet event JSON sur sink] | |
| 74 | + O --> O3[Notifie chaque détecteur] | |
| 75 | + O3 --> P1[BruteForce / UserEnum / KeyScan] | |
| 76 | + P1 --> Q{Seuil atteint ?} | |
| 77 | + Q -- Oui --> R[BanManager.auto_ban] | |
| 78 | + R --> S[Insert ban + event IpBanned] | |
| 79 | + Q -- Non --> Z[Fin] | |
| 80 | +``` | |
| 81 | + | |
| 82 | +### Trois plans qui interagissent | |
| 83 | + | |
| 84 | +| Plan | Composants | Caractéristique principale | | |
| 85 | +|---|---|---| | |
| 86 | +| **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` | | |
| 87 | +| **Asynchrone, push** | `AuthTracker.record_auth_attempt` → détecteurs → `BanManager.auto_ban` | Tâches Tokio courtes, jamais bloquantes | | |
| 88 | +| **Persistant** | `HybridStore`, `PostgresStore` | Write-through périodique, `rehydrate` au boot | | |
| 89 | + | |
| 90 | +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). | |
| 91 | + | |
| 92 | +--- | |
| 93 | + | |
| 94 | +## 4. Les décisions de conception | |
| 95 | + | |
| 96 | +### 4.1 Listener wrapper plutôt que middleware russh | |
| 97 | + | |
| 98 | +ssh-guard intercale `SecureListener` **entre** `TcpListener` et `russh`. Trois raisons : | |
| 99 | + | |
| 100 | +- Beaucoup de drops (flood, ban) doivent se faire **avant** d'allouer un handler `russh` (RAM, threads). Un middleware `russh` coûterait des allocations inutiles à chaque scan de port. | |
| 101 | +- L'extraction d'IP via PROXY protocol nécessite de **peeker** les premiers octets du `TcpStream` avant que `russh` ne le voie — un middleware de niveau session ne peut pas faire ça. | |
| 102 | +- Découplage strict : ssh-guard ne dépend pas de `russh` (testable en standalone, réutilisable si on remplace la lib SSH). | |
| 103 | + | |
| 104 | +### 4.2 Push-based detectors | |
| 105 | + | |
| 106 | +Chaque détecteur expose `async fn on_event(event: &GuardEvent)`. L'`AuthTracker` les appelle après chaque événement. Avantages : | |
| 107 | + | |
| 108 | +- Pas de scheduler de fond à entretenir. Le détecteur se réveille uniquement quand il y a quelque chose à analyser. | |
| 109 | +- Idempotence facile : un second appel après franchissement du seuil retombe sur un `auto_ban` no-op (déjà banni). | |
| 110 | +- Test déterministe : on injecte des événements synthétiques et on observe le sink — pas de `tokio::time::sleep` à attendre. | |
| 111 | + | |
| 112 | +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. | |
| 113 | + | |
| 114 | +### 4.3 Priorité ACL : `deny > auto_ban > allow > default` | |
| 115 | + | |
| 116 | +L'ordre est exécuté tel quel dans `BanManager.effective_status`. Conséquences : | |
| 117 | + | |
| 118 | +- **`deny` admin > tout** : un opérateur peut brûler une IP même si elle a été allowlistée par erreur ailleurs. | |
| 119 | +- **`auto_ban` > `allow`** : un ban auto déjà posé ne saute pas si on allowliste après coup. Pour le lever, il faut explicitement `unban`. C'est un choix conservateur : un allow ne doit pas effacer la trace d'un comportement passé. | |
| 120 | +- **`allow` > `default`** : une IP allowlistée bypasse les détecteurs (mais ses événements restent loggés pour audit). | |
| 121 | + | |
| 122 | +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. | |
| 123 | + | |
| 124 | +### 4.4 Fail-open sur erreur de store | |
| 125 | + | |
| 126 | +Quand `BanManager.effective_status` reçoit `Err(StoreError)`, le listener log `warn` et **laisse passer**. C'est un compromis : | |
| 127 | + | |
| 128 | +- **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). | |
| 129 | +- **Contre** : pendant la fenêtre de panne, un attaquant pourrait passer (mais l'auth `russh` reste opérationnelle, donc l'attaquant ne devient utilisateur que s'il connaît une clé valide). | |
| 130 | + | |
| 131 | +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. | |
| 132 | + | |
| 133 | +### 4.5 Dry-run avec émission d'événements | |
| 134 | + | |
| 135 | +`SSH_GUARD_DRY_RUN=true` désactive la persistance des bans mais **continue à émettre les événements `IpBanned`**. C'est le mode parfait pour : | |
| 136 | + | |
| 137 | +- valider un nouveau seuil en prod sans risquer de bannir un utilisateur ; | |
| 138 | +- alimenter fail2ban (qui ferait le ban réel via UFW/iptables) tout en laissant ssh-guard observer ; | |
| 139 | +- répliquer le comportement d'un détecteur dans un environnement de pré-prod. | |
| 140 | + | |
| 141 | +### 4.6 Stockage hybride par défaut | |
| 142 | + | |
| 143 | +Trois backends, mais le défaut **hybrid** est presque toujours le bon : | |
| 144 | + | |
| 145 | +- les lectures hot path passent par DashMap (latence µs) ; | |
| 146 | +- chaque écriture est **synchrone côté mémoire** + **asynchrone vers PostgreSQL** (write-through) ; | |
| 147 | +- au boot, `rehydrate` repeuple 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). | |
| 148 | + | |
| 149 | +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. | |
| 150 | + | |
| 151 | +### 4.7 Schéma JSON stable | |
| 152 | + | |
| 153 | +`#[serde(tag = "event", rename_all = "snake_case")]` produit `{"event":"<nom>","ts":"...","ip":"...",...}`. La règle inviolable : | |
| 154 | + | |
| 155 | +- ajouter un nouveau variant ou un nouveau champ optionnel : OK ; | |
| 156 | +- renommer un variant ou un champ existant : breaking change → nouveau nom + period de transition. | |
| 157 | + | |
| 158 | +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. | |
| 159 | + | |
| 160 | +--- | |
| 161 | + | |
| 162 | +## 5. Ce que ssh-guard ne fait pas (volontairement) | |
| 163 | + | |
| 164 | +| Hors périmètre | Pourquoi | Couvert par | | |
| 165 | +|---|---|---| | |
| 166 | +| Inspection des payloads SSH | Niveau TCP uniquement, ssh-guard n'a pas la clé d'hôte | `russh` lui-même | | |
| 167 | +| Authentification utilisateur | Domaine de `gitrust-core::SshKeyService` | `gitrust-ssh` | | |
| 168 | +| Limites de débit applicatives (push trop gros) | Concerne le pack-protocol, pas la connexion | `gitrust-git` | | |
| 169 | +| Distribution multi-noeuds | Pas d'algorithme de gossip, pas de Raft | À traiter par le store backend (Postgres partagé) | | |
| 170 | +| GeoIP / blocage par pays | Décision politique, pas technique | Reverse-proxy ou firewall en amont | | |
| 171 | + | |
| 172 | +--- | |
| 173 | + | |
| 174 | +## 6. Implications pour les contributeurs | |
| 175 | + | |
| 176 | +### Quand ajouter un détecteur | |
| 177 | + | |
| 178 | +Si un nouveau motif d'attaque émerge (ex. : « attaques par chronométrie sur les fingerprints »), suivre ce squelette : | |
| 179 | + | |
| 180 | +1. Ajouter un fichier `src/detector/<nom>.rs` calqué sur `brute_force.rs`. | |
| 181 | +2. Implémenter `pub async fn on_event(&self, event: &GuardEvent)` qui filtre les événements pertinents et lit l'agrégat via `GuardStore`. | |
| 182 | +3. Ajouter une variante `BanReason::<Nom>` et un `GuardEvent::<Nom>Detected { ... }` dans `events.rs`. Mettre à jour `event_name` et le test `event_name_matches_serde_tag`. | |
| 183 | +4. Câbler le détecteur dans `runtime.rs::build` (instanciation conditionnelle si `threshold.is_disabled()`). | |
| 184 | +5. Câbler dans `tracker.rs` (ajout d'un `Option<Arc<NouveauDetector>>` + appel dans `record_auth_attempt`). | |
| 185 | +6. Documenter le nouveau seuil dans `config.rs` (env var + preset par profil) et dans la page admin de référence. | |
| 186 | + | |
| 187 | +### Quand modifier le format d'événement | |
| 188 | + | |
| 189 | +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. | |
| 190 | + | |
| 191 | +### Quand changer une priorité ACL | |
| 192 | + | |
| 193 | +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. | |
| 194 | + | |
| 195 | +--- | |
| 196 | + | |
| 197 | +## 7. Vérifier votre compréhension | |
| 198 | + | |
| 199 | +1. Une connexion arrive avec un en-tête PROXY v2 valide depuis `192.168.10.5`. La config est `SSH_GUARD_PROFILE=nginx` (donc `trusted_proxies` = `127.0.0.1/32, ::1/128`). Quel est l'`AcceptOutcome` ? Pourquoi ? | |
| 200 | +2. Une IP est dans la denylist admin **et** vient d'être bannie automatiquement pour brute force. Quel `EffectiveStatus` retourne `effective_status` ? L'ordre des deux lookups dans le code change-t-il quelque chose ? | |
| 201 | +3. 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` ? | |
| 202 | + | |
| 203 | +--- | |
| 204 | + | |
| 205 | +## 8. Pour aller plus loin | |
| 206 | + | |
| 207 | +- [Crate gitrust-ssh-guard](../reference/ssh-guard-crate.md) — référence structurelle (modules, types, API publique) | |
| 208 | +- [Architecture des crates](../reference/architecture-crates.md) — position dans le graphe de dépendances | |
| 209 | +- [Vue d'ensemble de l'architecture](vue-ensemble-architecture.md) — où ssh-guard s'insère dans le runtime gitrust | |
| 210 | +- [ssh-guard : détection d'attaques SSH](../../administration_manual/explanation/ssh-guard-detection.md) — vue admin de la chaîne de détection | |
| 211 | +- [Conformité ANSSI PA-074](../../administration_manual/reference/conformite-anssi-pa074.md) — directives applicables au code de la crate |
M
developer_manual/explanation/vue-ensemble-architecture.md
+11
-4
@@ -18,6 +18,7 @@ graph TB
| 18 | 18 | subgraph "Crates Gitrust" |
| 19 | 19 | Web["gitrust-web<br/><i>Routes HTTP, templates,<br/>handlers Axum</i>"] |
| 20 | 20 | SSH["gitrust-ssh<br/><i>Serveur SSH (russh),<br/>auth par clé, sessions</i>"] |
| 21 | + SshGuard["gitrust-ssh-guard<br/><i>SecureListener, détecteurs,<br/>BanManager, AuthTracker</i>"] | |
| 21 | 22 | Hooks["gitrust-hooks<br/><i>impl RustwardenHooks<br/>(on_user_registered, ...)</i>"] |
| 22 | 23 | Core["gitrust-core<br/><i>Models, services, migrations,<br/>rôles, types, DTOs</i>"] |
| 23 | 24 | Git["gitrust-git<br/><i>Bare repos, tree browser,<br/>pack protocol (git2)</i>"] |
@@ -41,18 +42,23 @@ graph TB
| 41 | 42 | end |
| 42 | 43 | |
| 43 | 44 | Browser -->|HTTP :4000| Web |
| 44 | - GitCLI -->|SSH :2222| SSH | |
| 45 | + GitCLI -->|SSH :2222| SshGuard | |
| 46 | + SshGuard -->|"AcceptOutcome::Accepted"| SSH | |
| 45 | 47 | GitCLI -->|HTTPS :4000| Web |
| 46 | 48 | |
| 47 | 49 | Main --> Web |
| 48 | 50 | Main --> SSH |
| 51 | + Main --> SshGuard | |
| 49 | 52 | Main --> Hooks |
| 50 | 53 | Main -.->|"si CI_ENABLED"| CiWorker |
| 51 | 54 | |
| 52 | 55 | Web --> Core |
| 53 | 56 | Web --> Git |
| 57 | + Web -.->|"admin ACL/ban"| SshGuard | |
| 54 | 58 | SSH --> Core |
| 55 | 59 | SSH --> Git |
| 60 | + SSH --> SshGuard | |
| 61 | + SshGuard --> Core | |
| 56 | 62 | Hooks --> Core |
| 57 | 63 | |
| 58 | 64 | Core --> RW |
@@ -148,6 +154,7 @@ Plutôt que d'implémenter un runner CI from scratch, gitrust délègue l'exécu
| 148 | 154 | |
| 149 | 155 | ## Pour aller plus loin |
| 150 | 156 | |
| 151 | -- Référence structurelle complète (tables de modules, routes, dépendances) : `developer_manual/reference/architecture-crates.md` | |
| 152 | -- Diagrammes de séquence détaillés (clone SSH, push, permissions) : `developer_manual/explanation/flux-requetes.md` | |
| 153 | -- Règles de code et gates QA : `developer_manual/reference/regles-qa-anssi.md` | |
| 157 | +- Référence structurelle complète (tables de modules, routes, dépendances) : [Architecture des crates](../reference/architecture-crates.md) | |
| 158 | +- Diagrammes de séquence détaillés (clone SSH, push, permissions) : [Flux de requêtes](flux-requetes.md) | |
| 159 | +- Conception de la couche de durcissement SSH : [Conception de ssh-guard](ssh-guard-conception.md) et [Crate gitrust-ssh-guard](../reference/ssh-guard-crate.md) | |
| 160 | +- Règles de code et gates QA : [Règles QA et conformité ANSSI](../reference/regles-qa-anssi.md) |
M
developer_manual/reference/architecture-crates.md
+28
-4
@@ -12,6 +12,7 @@ graph LR
| 12 | 12 | Core["gitrust-core"] |
| 13 | 13 | Hooks["gitrust-hooks"] |
| 14 | 14 | SSH["gitrust-ssh"] |
| 15 | + SshGuard["gitrust-ssh-guard"] | |
| 15 | 16 | Web["gitrust-web"] |
| 16 | 17 | Bin["gitrust (binaire)"] |
| 17 | 18 |
@@ -23,14 +24,19 @@ graph LR
| 23 | 24 | |
| 24 | 25 | SSH --> Core |
| 25 | 26 | SSH --> Git |
| 27 | + SSH --> SshGuard | |
| 28 | + | |
| 29 | + SshGuard -.->|"SeaORM bans/ACL/events"| Core | |
| 26 | 30 | |
| 27 | 31 | Web --> Core |
| 28 | 32 | Web --> Git |
| 29 | 33 | Web -.->|"CI pages"| Git |
| 34 | + Web -.->|"admin ACL/ban via Extension"| SshGuard | |
| 30 | 35 | |
| 31 | 36 | Bin --> Core |
| 32 | 37 | Bin --> Git |
| 33 | 38 | Bin --> SSH |
| 39 | + Bin --> SshGuard | |
| 34 | 40 | Bin --> Web |
| 35 | 41 | Bin --> Hooks |
| 36 | 42 | Bin --> RW |
@@ -89,11 +95,29 @@ Serveur SSH basé sur `russh`. Authentification par clé publique.
| 89 | 95 | |
| 90 | 96 | | Module (prévu) | Rôle | |
| 91 | 97 | |----------------|------| |
| 92 | -| `server` | Démarrage, génération clé hôte Ed25519 | | |
| 93 | -| `auth` | Authentification par fingerprint -> SshKeyService | | |
| 94 | -| `session` | Handler SSH (exec, shell) | | |
| 98 | +| `server` | Démarrage, génération clé hôte Ed25519, wrapper du `TcpListener` par `SecureListener` (ssh-guard) | | |
| 99 | +| `auth` | Authentification par fingerprint -> SshKeyService, appel à `AuthTracker.record_auth_attempt` après chaque tentative | | |
| 100 | +| `session` | Handler SSH (exec, shell), porte `ClientIdentity` du listener jusqu'aux décisions auth | | |
| 95 | 101 | | `command_handler` | Parsing `git-upload-pack`/`git-receive-pack` | |
| 96 | 102 | |
| 103 | +### gitrust-ssh-guard | |
| 104 | + | |
| 105 | +Couche de durcissement SSH intercalée entre le `TcpListener` et `russh`. Voir la page dédiée [Crate gitrust-ssh-guard](ssh-guard-crate.md) pour le détail des modules et l'API publique. Synthèse : | |
| 106 | + | |
| 107 | +| Module | Rôle | | |
| 108 | +|--------|------| | |
| 109 | +| `config` | `GuardConfig::from_env()` — lecture `SSH_GUARD_*`, sélection `DeploymentProfile`, validation | | |
| 110 | +| `runtime` | `GuardHandles::build(db)` — assemblage partageable entre serveur SSH et routeur admin | | |
| 111 | +| `listener` | `SecureListener` wrappe `TcpListener` : extraction IP réelle (PROXY v1/v2), ACL, flood, retour `AcceptOutcome` | | |
| 112 | +| `proxy` | Parseur PROXY protocol v1/v2 avec timeout | | |
| 113 | +| `identity` | `ClientIdentity` — IP + session_id + user/fingerprint enrichis pendant l'auth | | |
| 114 | +| `tracker` | `AuthTracker` — persiste, émet, dispatche aux détecteurs | | |
| 115 | +| `ban` | `BanManager` + `EffectiveStatus` — priorité `deny > auto_ban > allow > default` | | |
| 116 | +| `events` | `GuardEvent` — schéma JSON stable (tag = "event") pour fail2ban / SIEM | | |
| 117 | +| `sinks` | `TracingSink`, `FileSink`, `MultiSink` — destination des événements | | |
| 118 | +| `detector/{brute_force,user_enumeration,key_scanning,connection_flood}` | 4 motifs d'attaque, push-based | | |
| 119 | +| `store/{memory,postgres,hybrid}` | Backend bans/ACL/events — `hybrid` (RAM + write-through) par défaut | | |
| 120 | + | |
| 97 | 121 | ### gitrust-web |
| 98 | 122 | |
| 99 | 123 | Interface web SSR (Server-Side Rendering) avec Askama (templates compilés) + HTMX (interactions dynamiques). Fonctionne en mode `headless()` : les pages UI du framework (`rustwarden-ui`) ne sont pas montées, gitrust-web réimplémente toutes les pages avec un design spécifique (sidebar contextuelle, navigation Git, DaisyUI). |
@@ -249,7 +273,7 @@ Héritage **team → repo** : les variables définies au niveau team sont partag
| 249 | 273 | |
| 250 | 274 | | Directive | Scope | |
| 251 | 275 | |-----------|-------| |
| 252 | -| `#![forbid(unsafe_code)]` | gitrust-core, gitrust-web, gitrust-hooks | | |
| 276 | +| `#![forbid(unsafe_code)]` | gitrust-core, gitrust-web, gitrust-hooks, gitrust-ssh-guard | | |
| 253 | 277 | | `#![deny(unsafe_code)]` | gitrust-git, gitrust-ssh (FFI libgit2/russh) | |
| 254 | 278 | | `deny(clippy::unwrap_used, expect_used, panic)` | Tous les crates | |
| 255 | 279 | | `deny(clippy::indexing_slicing)` | Tous les crates | |
A
developer_manual/reference/ssh-guard-crate.md
+315
-0
@@ -0,0 +1,315 @@
| 1 | +# Crate `gitrust-ssh-guard` | |
| 2 | + | |
| 3 | +Référence structurelle de la crate de durcissement SSH. Pour la motivation et le rationale de design, voir [Conception de ssh-guard](../explanation/ssh-guard-conception.md). Pour l'exploitation côté admin, voir [Configurer ssh-guard](../../administration_manual/how-to/configurer-ssh-guard.md). | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 1. Position dans l'arborescence | |
| 8 | + | |
| 9 | +``` | |
| 10 | +crates/ | |
| 11 | +├── gitrust-ssh-guard/ | |
| 12 | +│ ├── Cargo.toml | |
| 13 | +│ └── src/ | |
| 14 | +│ ├── lib.rs (re-exports publics) | |
| 15 | +│ ├── config.rs (SSH_GUARD_*, profils, validation) | |
| 16 | +│ ├── runtime.rs (GuardHandles : assemblage) | |
| 17 | +│ ├── listener.rs (SecureListener, AcceptOutcome) | |
| 18 | +│ ├── proxy.rs (parseur PROXY protocol v1/v2) | |
| 19 | +│ ├── identity.rs (ClientIdentity) | |
| 20 | +│ ├── tracker.rs (AuthTracker, dispatch détecteurs) | |
| 21 | +│ ├── ban.rs (BanManager, EffectiveStatus) | |
| 22 | +│ ├── events.rs (GuardEvent, GuardEventSink) | |
| 23 | +│ ├── sinks.rs (TracingSink, FileSink, MultiSink) | |
| 24 | +│ ├── detector/ | |
| 25 | +│ │ ├── mod.rs | |
| 26 | +│ │ ├── brute_force.rs | |
| 27 | +│ │ ├── user_enumeration.rs | |
| 28 | +│ │ ├── key_scanning.rs | |
| 29 | +│ │ └── connection_flood.rs | |
| 30 | +│ └── store/ | |
| 31 | +│ ├── mod.rs (trait GuardStore) | |
| 32 | +│ ├── entities.rs (modèles SeaORM) | |
| 33 | +│ ├── memory.rs (MemoryStore — DashMap) | |
| 34 | +│ ├── postgres.rs (PostgresStore — write-through) | |
| 35 | +│ └── hybrid.rs (HybridStore — RAM chaude + DB) | |
| 36 | +``` | |
| 37 | + | |
| 38 | +## 2. Tableau des modules | |
| 39 | + | |
| 40 | +| Module | Rôle | Types principaux | | |
| 41 | +|---|---|---| | |
| 42 | +| `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` | | |
| 43 | +| `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` | | |
| 44 | +| `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` | | |
| 45 | +| `proxy` | Parseur PROXY protocol v1 (texte HAProxy legacy) et v2 (binaire nginx stream / HAProxy moderne) avec timeout dédié. | `parse_header`, `ParsedHeader`, `ProxyError` | | |
| 46 | +| `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` | | |
| 47 | +| `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` | | |
| 48 | +| `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` | | |
| 49 | +| `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` | | |
| 50 | +| `sinks` | Implémentations de `GuardEventSink` : `tracing` (stderr / journald), fichier en append (ligne JSON), fan-out vers plusieurs sinks. | `TracingSink`, `FileSink`, `MultiSink`, `build_sink` | | |
| 51 | +| `detector::brute_force` | Compte les `AuthFailed` par IP dans la fenêtre, déclenche `auto_ban(BruteForce)` au seuil. | `BruteForceDetector` | | |
| 52 | +| `detector::user_enumeration` | Compte les usernames distincts essayés par IP dans la fenêtre. | `UserEnumerationDetector` | | |
| 53 | +| `detector::key_scanning` | Compte les fingerprints de clé distincts essayés par IP dans la fenêtre. | `KeyScanningDetector` | | |
| 54 | +| `detector::connection_flood` | Token bucket GCRA (`governor`) keyé par IP. Pas de ban persistant : drop immédiat au listener. | `ConnectionFloodDetector` | | |
| 55 | +| `store` | Trait abstrait pour bans, ACL et événements d'auth. Trois implémentations interchangeables. | `GuardStore`, `MemoryStore`, `PostgresStore`, `HybridStore`, `BanEntry`, `AclEntry`, `AclKind`, `RehydrateStats`, `StoreError` | | |
| 56 | + | |
| 57 | +--- | |
| 58 | + | |
| 59 | +## 3. Surface API publique (`lib.rs`) | |
| 60 | + | |
| 61 | +```rust | |
| 62 | +// Assemblage et runtime | |
| 63 | +pub use runtime::{BuildError, GuardHandles}; | |
| 64 | + | |
| 65 | +// Configuration | |
| 66 | +pub use config::{ | |
| 67 | + ConfigError, DeploymentProfile, DetectorThreshold, GuardConfig, | |
| 68 | + LogFormat, LogTarget, ProxyProtocol, StoreBackend, | |
| 69 | +}; | |
| 70 | + | |
| 71 | +// Listener | |
| 72 | +pub use listener::{AcceptError, AcceptOutcome, ProxyListenerConfig, SecureListener}; | |
| 73 | + | |
| 74 | +// Identité et tracking | |
| 75 | +pub use identity::ClientIdentity; | |
| 76 | +pub use tracker::{AuthOutcome, AuthTracker}; | |
| 77 | + | |
| 78 | +// Décision de ban | |
| 79 | +pub use ban::{BanManager, EffectiveStatus}; | |
| 80 | + | |
| 81 | +// Détecteurs | |
| 82 | +pub use detector::{ | |
| 83 | + BruteForceDetector, ConnectionFloodDetector, | |
| 84 | + KeyScanningDetector, UserEnumerationDetector, | |
| 85 | +}; | |
| 86 | + | |
| 87 | +// Événements et sinks | |
| 88 | +pub use events::{AuthMethod, BanReason, DropReason, GuardEvent, GuardEventSink}; | |
| 89 | +pub use sinks::{build_sink, FileSink, MultiSink, TracingSink}; | |
| 90 | + | |
| 91 | +// Stockage | |
| 92 | +pub use store::{ | |
| 93 | + AclEntry, AclKind, BanEntry, GuardStore, HybridStore, | |
| 94 | + MemoryStore, PostgresStore, RehydrateStats, StoreError, | |
| 95 | +}; | |
| 96 | +``` | |
| 97 | + | |
| 98 | +Lints actifs sur la crate (alignés ANSSI PA-074) : | |
| 99 | + | |
| 100 | +```rust | |
| 101 | +#![forbid(unsafe_code)] | |
| 102 | +#![deny( | |
| 103 | + clippy::unwrap_used, | |
| 104 | + clippy::expect_used, | |
| 105 | + clippy::panic, | |
| 106 | + clippy::indexing_slicing, | |
| 107 | + clippy::mem_forget | |
| 108 | +)] | |
| 109 | +``` | |
| 110 | + | |
| 111 | +--- | |
| 112 | + | |
| 113 | +## 4. `GuardHandles` — point d'entrée du runtime | |
| 114 | + | |
| 115 | +`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 : | |
| 116 | + | |
| 117 | +- vers `gitrust_ssh::server::start_server` pour que `SecureListener` et `AuthTracker` partagent les mêmes `BanManager` / `GuardStore` ; | |
| 118 | +- 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. | |
| 119 | + | |
| 120 | +```rust | |
| 121 | +#[derive(Clone)] | |
| 122 | +pub struct GuardHandles { | |
| 123 | + pub config: GuardConfig, | |
| 124 | + pub store: Arc<dyn GuardStore>, | |
| 125 | + pub sink: Arc<dyn GuardEventSink>, | |
| 126 | + pub ban_manager: Arc<BanManager>, | |
| 127 | + pub tracker: Arc<AuthTracker>, | |
| 128 | + pub flood: Option<Arc<ConnectionFloodDetector>>, | |
| 129 | + pub proxy_config: Option<ProxyListenerConfig>, | |
| 130 | +} | |
| 131 | + | |
| 132 | +impl GuardHandles { | |
| 133 | + pub async fn build(db: DatabaseConnection) -> Result<Self, BuildError>; | |
| 134 | +} | |
| 135 | +``` | |
| 136 | + | |
| 137 | +`build` enchaîne : | |
| 138 | + | |
| 139 | +1. `GuardConfig::from_env()` (lecture + validation). | |
| 140 | +2. `build_sink(&config)` selon `LogTarget`. | |
| 141 | +3. Construction du store selon `SSH_GUARD_STORE_BACKEND` : | |
| 142 | + - `memory` → `MemoryStore` (rien en DB) ; | |
| 143 | + - `postgres` → `PostgresStore` (chaque écriture en DB) ; | |
| 144 | + - `hybrid` → `HybridStore` (RAM chaude + flush write-through périodique + `rehydrate` au boot pour repeupler la mémoire depuis la DB). | |
| 145 | +4. Instanciation du `BanManager`. | |
| 146 | +5. Instanciation des détecteurs (chaque détecteur dont le seuil est désactivé — `u32::MAX` — n'est pas instancié). | |
| 147 | +6. Construction de l'`AuthTracker` avec les détecteurs actifs. | |
| 148 | +7. Construction de `ProxyListenerConfig` si `proxy_protocol != Disabled`. | |
| 149 | + | |
| 150 | +--- | |
| 151 | + | |
| 152 | +## 5. `SecureListener` — séquence d'`accept` | |
| 153 | + | |
| 154 | +```rust | |
| 155 | +pub enum AcceptOutcome { | |
| 156 | + Accepted { stream: TcpStream, identity: ClientIdentity }, | |
| 157 | + Dropped { ip: IpAddr, reason: DropReason }, | |
| 158 | +} | |
| 159 | + | |
| 160 | +impl SecureListener { | |
| 161 | + pub async fn accept(&self) -> Result<AcceptOutcome, AcceptError>; | |
| 162 | +} | |
| 163 | +``` | |
| 164 | + | |
| 165 | +Étapes appliquées à chaque connexion entrante : | |
| 166 | + | |
| 167 | +1. `tcp.accept()` → `(stream, peer)`. | |
| 168 | +2. `resolve_real_ip(stream, peer.ip())` : | |
| 169 | + - profil direct (pas de `proxy_config`) → IP = `peer.ip()` ; | |
| 170 | + - profil proxifié → vérifie que `peer.ip()` ∈ `trusted_proxies`, puis parse l'en-tête PROXY pour extraire l'IP cliente réelle ; | |
| 171 | + - en mode `strict`, un parsing en échec retourne `Err(DropReason::ProxyHeaderInvalid|Missing|UntrustedProxy)` ; en mode souple, fallback sur `peer.ip()` avec warn. | |
| 172 | +3. `ban_manager.effective_status(ip)` : | |
| 173 | + - `DeniedByAcl` → `Dropped { reason: Banned }` ; | |
| 174 | + - `BannedAuto(_)` → `Dropped { reason: Banned }` ; | |
| 175 | + - `AllowListed` → `Accepted` immédiat (bypass flood) ; | |
| 176 | + - `Normal` → étape 4. | |
| 177 | +4. Si `flood` actif : `flood.check(ip)`. Sur false → `Dropped { reason: FloodLimit }` (les events sont déjà émis par `flood.check`). | |
| 178 | +5. Sinon : construction du `ClientIdentity`, émission de `ConnectionAccepted`, retour `Accepted`. | |
| 179 | + | |
| 180 | +Toute erreur du store est traitée en **fail-open** (warn + on laisse passer), pour ne pas couper le service quand PostgreSQL est indisponible. | |
| 181 | + | |
| 182 | +--- | |
| 183 | + | |
| 184 | +## 6. `BanManager` — priorités d'ACL | |
| 185 | + | |
| 186 | +```rust | |
| 187 | +pub enum EffectiveStatus { | |
| 188 | + Normal, | |
| 189 | + AllowListed, | |
| 190 | + DeniedByAcl, | |
| 191 | + BannedAuto(Box<BanEntry>), | |
| 192 | +} | |
| 193 | +``` | |
| 194 | + | |
| 195 | +Priorité de résolution dans `effective_status(ip)` : | |
| 196 | + | |
| 197 | +1. `acl_match(ip) == Some(Deny)` → `DeniedByAcl`. | |
| 198 | +2. `bans_covering(ip)` non vide → `BannedAuto(ban)`. | |
| 199 | +3. `acl_match(ip) == Some(Allow)` → `AllowListed`. | |
| 200 | +4. Sinon → `Normal`. | |
| 201 | + | |
| 202 | +Un appel `auto_ban(ip, reason)` est **no-op** dans trois cas : | |
| 203 | + | |
| 204 | +- `dry_run` actif (un événement `IpBanned` est tout de même émis pour fail2ban / observabilité) ; | |
| 205 | +- l'IP est allowlistée ; | |
| 206 | +- un ban auto actif couvre déjà l'IP (évite le spam d'événements `IpBanned`). | |
| 207 | + | |
| 208 | +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`. | |
| 209 | + | |
| 210 | +--- | |
| 211 | + | |
| 212 | +## 7. `AuthTracker` — pivot de la chaîne d'événements | |
| 213 | + | |
| 214 | +```rust | |
| 215 | +pub enum AuthOutcome { Success, Failure } | |
| 216 | + | |
| 217 | +impl AuthTracker { | |
| 218 | + pub async fn record_auth_attempt( | |
| 219 | + &self, | |
| 220 | + identity: &ClientIdentity, | |
| 221 | + method: AuthMethod, | |
| 222 | + outcome: AuthOutcome, | |
| 223 | + ); | |
| 224 | +} | |
| 225 | +``` | |
| 226 | + | |
| 227 | +Pour chaque appel : | |
| 228 | + | |
| 229 | +1. Construit `GuardEvent::AuthSucceeded` ou `AuthFailed` selon `outcome`. | |
| 230 | +2. `store.record_event(&event)` — alimente la table consultée par les détecteurs. | |
| 231 | +3. `sink.emit(&event)` — alimente fail2ban / observabilité. | |
| 232 | +4. Pour chaque détecteur configuré : `detector.on_event(&event).await`. | |
| 233 | + | |
| 234 | +Côté `gitrust-ssh`, le handler `russh` appelle `record_auth_attempt` après chaque tentative d'authentification (clé publique, password, keyboard-interactive). | |
| 235 | + | |
| 236 | +--- | |
| 237 | + | |
| 238 | +## 8. Schéma JSON des événements | |
| 239 | + | |
| 240 | +Tous les `GuardEvent` se sérialisent avec `#[serde(tag = "event", rename_all = "snake_case")]`. La forme stable est : | |
| 241 | + | |
| 242 | +```json | |
| 243 | +{"event":"<nom_variant>","ts":"...","ip":"...", ...} | |
| 244 | +``` | |
| 245 | + | |
| 246 | +Pour le détail des champs par variant et leur usage côté admin (fail2ban, SIEM), voir [Événements ssh-guard (JSON)](../../administration_manual/reference/ssh-guard-evenements.md). | |
| 247 | + | |
| 248 | +Convention de stabilité : | |
| 249 | + | |
| 250 | +- ajouter un nouveau variant n'est **pas** un breaking change pour les consommateurs ; | |
| 251 | +- ajouter un champ optionnel à un variant existant ne l'est pas non plus ; | |
| 252 | +- renommer un champ ou un variant **est** un breaking change et nécessite un nouveau nom. | |
| 253 | + | |
| 254 | +--- | |
| 255 | + | |
| 256 | +## 9. Backends de stockage (`GuardStore`) | |
| 257 | + | |
| 258 | +| Backend | Vie des bans/ACL | Latence lecture | Cas d'usage | | |
| 259 | +|---|---|---|---| | |
| 260 | +| `MemoryStore` | Perdues au restart | DashMap, lock-free | Tests, profil `private`, dev | | |
| 261 | +| `PostgresStore` | Persistantes | Round-trip DB par lecture | Audits stricts, instances multi-noeuds (sans cache RAM) | | |
| 262 | +| `HybridStore` | RAM chaude + write-through DB, `rehydrate` au boot | DashMap | **Défaut recommandé** : performance + persistance | | |
| 263 | + | |
| 264 | +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`. | |
| 265 | + | |
| 266 | +--- | |
| 267 | + | |
| 268 | +## 10. Intégration côté `gitrust-ssh` | |
| 269 | + | |
| 270 | +Pseudo-code condensé du wiring effectif (`crates/gitrust-ssh/src/server.rs`) : | |
| 271 | + | |
| 272 | +```rust | |
| 273 | +let secure = SecureListener::new_with_proxy( | |
| 274 | + tcp_listener, | |
| 275 | + guard.ban_manager.clone(), | |
| 276 | + guard.flood.clone(), | |
| 277 | + guard.sink.clone(), | |
| 278 | + guard.proxy_config.clone(), | |
| 279 | +); | |
| 280 | + | |
| 281 | +loop { | |
| 282 | + match secure.accept().await? { | |
| 283 | + AcceptOutcome::Accepted { stream, identity } => { | |
| 284 | + let session = GitSession::new_with_guard( | |
| 285 | + db.clone(), ssh_config.clone(), ci_tx.clone(), | |
| 286 | + identity, guard.tracker.clone(), | |
| 287 | + ); | |
| 288 | + tokio::spawn(async move { russh::server::run_stream(/* ... */).await }); | |
| 289 | + } | |
| 290 | + AcceptOutcome::Dropped { .. } => { | |
| 291 | + // L'événement ConnectionDropped a déjà été émis par le listener. | |
| 292 | + } | |
| 293 | + } | |
| 294 | +} | |
| 295 | +``` | |
| 296 | + | |
| 297 | +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é. | |
| 298 | + | |
| 299 | +--- | |
| 300 | + | |
| 301 | +## 11. Tests | |
| 302 | + | |
| 303 | +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. | |
| 304 | + | |
| 305 | +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/`. | |
| 306 | + | |
| 307 | +--- | |
| 308 | + | |
| 309 | +## 12. Voir aussi | |
| 310 | + | |
| 311 | +- [Conception de ssh-guard](../explanation/ssh-guard-conception.md) — pourquoi cette crate, modèle de menace, design async | |
| 312 | +- [Architecture des crates](architecture-crates.md) — position de ssh-guard dans le graphe de dépendances | |
| 313 | +- [Configurer ssh-guard](../../administration_manual/how-to/configurer-ssh-guard.md) — recettes par profil de déploiement | |
| 314 | +- [Variables d'environnement — section SSH_GUARD_*](../../administration_manual/reference/variables-environnement.md) | |
| 315 | +- [Événements ssh-guard (JSON)](../../administration_manual/reference/ssh-guard-evenements.md) |
A
girust_doc.code-workspace
+11
-0
@@ -0,0 +1,11 @@
| 1 | +{ | |
| 2 | + "folders": [ | |
| 3 | + { | |
| 4 | + "path": "." | |
| 5 | + }, | |
| 6 | + { | |
| 7 | + "path": "../gitrust" | |
| 8 | + } | |
| 9 | + ], | |
| 10 | + "settings": {} | |
| 11 | +} | |
| 11 | < \ No newline at end of file |
A
scripts/build-pdf.py
+39
-0
@@ -0,0 +1,39 @@
| 1 | +#!/usr/bin/env python3 | |
| 2 | +""" | |
| 3 | +Génère un PDF par langue à partir des fichiers book/<lang>/print.html. | |
| 4 | + | |
| 5 | +Installation (une fois) : | |
| 6 | + pip install weasyprint | |
| 7 | + | |
| 8 | +Usage : | |
| 9 | + ./scripts/build-pdf.py # toutes les langues | |
| 10 | + ./scripts/build-pdf.py fr en # langues spécifiques | |
| 11 | + OUT_DIR=./dist ./scripts/build-pdf.py # dossier de sortie custom | |
| 12 | + | |
| 13 | +Sortie par défaut : book/<lang>/documentation.pdf | |
| 14 | +""" | |
| 15 | + | |
| 16 | +import os | |
| 17 | +import sys | |
| 18 | +from pathlib import Path | |
| 19 | +from weasyprint import HTML | |
| 20 | + | |
| 21 | +ROOT = Path(__file__).resolve().parent.parent | |
| 22 | +BOOK = ROOT / "book" | |
| 23 | +LANGS = sys.argv[1:] or ["fr", "en", "de", "es", "pt", "it"] | |
| 24 | +OUT_DIR = Path(os.environ["OUT_DIR"]) if os.environ.get("OUT_DIR") else ROOT / "pdf" | |
| 25 | +OUT_DIR.mkdir(parents=True, exist_ok=True) | |
| 26 | + | |
| 27 | +for lang in LANGS: | |
| 28 | + src = BOOK / lang / "print.html" | |
| 29 | + if not src.exists(): | |
| 30 | + print(f"✗ {src} introuvable — lance build-all-langs.sh d'abord", file=sys.stderr) | |
| 31 | + continue | |
| 32 | + | |
| 33 | + out = OUT_DIR / f"documentation-{lang}.pdf" | |
| 34 | + | |
| 35 | + print(f"→ {lang} : {src} → {out}") | |
| 36 | + HTML(filename=str(src), base_url=str(src.parent)).write_pdf(str(out)) | |
| 37 | + print(f" ✓ {out.stat().st_size // 1024} KB") | |
| 38 | + | |
| 39 | +print("\n✓ Terminé.") |
A
scripts/build-pdf/Cargo.lock
+7
-0
@@ -0,0 +1,7 @@
| 1 | +# This file is automatically @generated by Cargo. | |
| 2 | +# It is not intended for manual editing. | |
| 3 | +version = 4 | |
| 4 | + | |
| 5 | +[[package]] | |
| 6 | +name = "build-pdf" | |
| 7 | +version = "0.1.0" |
A
scripts/build-pdf/Cargo.toml
+12
-0
@@ -0,0 +1,12 @@
| 1 | +[package] | |
| 2 | +name = "build-pdf" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2021" | |
| 5 | +description = "Génère un PDF par langue à partir des fichiers book/<lang>/print.html" | |
| 6 | + | |
| 7 | +[[bin]] | |
| 8 | +name = "build-pdf" | |
| 9 | +path = "src/main.rs" | |
| 10 | + | |
| 11 | +[dependencies] | |
| 12 | +# Appelle chromium en sous-processus — aucune dépendance runtime Rust. |
A
scripts/build-pdf/src/main.rs
+129
-0
@@ -0,0 +1,129 @@
| 1 | +//! Génère un PDF par langue à partir de `book/<lang>/print.html`. | |
| 2 | +//! | |
| 3 | +//! Backend : `wkhtmltopdf` (léger, stable, gère le JS via QtWebKit). | |
| 4 | +//! Fallback optionnel : `chromium --headless --print-to-pdf`. | |
| 5 | +//! | |
| 6 | +//! Usage : | |
| 7 | +//! cargo run --release --manifest-path scripts/build-pdf/Cargo.toml | |
| 8 | +//! cargo run --release --manifest-path scripts/build-pdf/Cargo.toml -- fr en | |
| 9 | +//! PDF_BACKEND=chromium cargo run --release --manifest-path scripts/build-pdf/Cargo.toml -- fr | |
| 10 | +//! | |
| 11 | +//! Sortie : `book/<lang>/documentation-<lang>.pdf` | |
| 12 | + | |
| 13 | +use std::{env, fs, path::PathBuf, process::Command}; | |
| 14 | + | |
| 15 | +const ALL_LANGS: &[&str] = &["fr", "en", "de", "es", "pt", "it"]; | |
| 16 | + | |
| 17 | +enum Backend { | |
| 18 | + Wkhtmltopdf(String), | |
| 19 | + Chromium(String), | |
| 20 | +} | |
| 21 | + | |
| 22 | +fn pick_backend() -> Option<Backend> { | |
| 23 | + let forced = env::var("PDF_BACKEND").unwrap_or_default(); | |
| 24 | + let try_wk = forced.is_empty() || forced == "wkhtmltopdf"; | |
| 25 | + let try_cr = forced.is_empty() || forced == "chromium"; | |
| 26 | + | |
| 27 | + if try_wk { | |
| 28 | + if Command::new("wkhtmltopdf").arg("--version").output().is_ok() { | |
| 29 | + return Some(Backend::Wkhtmltopdf("wkhtmltopdf".into())); | |
| 30 | + } | |
| 31 | + } | |
| 32 | + if try_cr { | |
| 33 | + for bin in ["chromium-browser", "chromium", "google-chrome"] { | |
| 34 | + if Command::new(bin).arg("--version").output().is_ok() { | |
| 35 | + return Some(Backend::Chromium(bin.into())); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + } | |
| 39 | + None | |
| 40 | +} | |
| 41 | + | |
| 42 | +fn render_wkhtmltopdf(bin: &str, src: &PathBuf, out: &PathBuf, lang: &str) -> std::io::Result<bool> { | |
| 43 | + let url = format!("file://{}", src.display()); | |
| 44 | + // Note : les switches --print-media-type, --header-*, --footer-* ne sont | |
| 45 | + // pas supportés par le wkhtmltopdf non-patché (distro standard). Désactivés | |
| 46 | + // ici pour éviter le bruit. Si tu veux en-tête/pied de page, installer la | |
| 47 | + // version avec Qt patché (wkhtmltopdf.org) et réactiver les flags. | |
| 48 | + let _ = lang; | |
| 49 | + let status = Command::new(bin) | |
| 50 | + .args([ | |
| 51 | + "--enable-local-file-access", | |
| 52 | + "--enable-javascript", | |
| 53 | + "--javascript-delay", "3000", // laisse 3s à Mermaid | |
| 54 | + "--encoding", "UTF-8", | |
| 55 | + "--margin-top", "15mm", | |
| 56 | + "--margin-bottom", "15mm", | |
| 57 | + "--margin-left", "15mm", | |
| 58 | + "--margin-right", "15mm", | |
| 59 | + "--quiet", | |
| 60 | + &url, | |
| 61 | + out.to_str().unwrap(), | |
| 62 | + ]) | |
| 63 | + .status()?; | |
| 64 | + Ok(status.success()) | |
| 65 | +} | |
| 66 | + | |
| 67 | +fn render_chromium(bin: &str, src: &PathBuf, out: &PathBuf, _lang: &str) -> std::io::Result<bool> { | |
| 68 | + let url = format!("file://{}", src.display()); | |
| 69 | + let status = Command::new(bin) | |
| 70 | + .args([ | |
| 71 | + "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", | |
| 72 | + "--disable-background-networking", "--disable-sync", "--no-first-run", | |
| 73 | + "--disable-extensions", "--no-default-browser-check", | |
| 74 | + "--virtual-time-budget=10000", | |
| 75 | + "--run-all-compositor-stages-before-draw", | |
| 76 | + &format!("--print-to-pdf={}", out.display()), | |
| 77 | + "--no-pdf-header-footer", | |
| 78 | + &url, | |
| 79 | + ]) | |
| 80 | + .status()?; | |
| 81 | + Ok(status.success()) | |
| 82 | +} | |
| 83 | + | |
| 84 | +fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 85 | + let backend = pick_backend() | |
| 86 | + .ok_or("Ni wkhtmltopdf ni chromium trouvés. Installer : apt install wkhtmltopdf")?; | |
| 87 | + let backend_name = match &backend { | |
| 88 | + Backend::Wkhtmltopdf(b) => format!("wkhtmltopdf ({b})"), | |
| 89 | + Backend::Chromium(b) => format!("chromium ({b})"), | |
| 90 | + }; | |
| 91 | + println!("→ Backend : {backend_name}"); | |
| 92 | + | |
| 93 | + let args: Vec<String> = env::args().skip(1).collect(); | |
| 94 | + let langs: Vec<&str> = if args.is_empty() { | |
| 95 | + ALL_LANGS.to_vec() | |
| 96 | + } else { | |
| 97 | + args.iter().map(String::as_str).collect() | |
| 98 | + }; | |
| 99 | + | |
| 100 | + let book = env::current_dir()?.join("book"); | |
| 101 | + let pdf_dir = env::current_dir()?.join("pdf"); | |
| 102 | + fs::create_dir_all(&pdf_dir)?; | |
| 103 | + | |
| 104 | + for lang in langs { | |
| 105 | + let src = book.join(lang).join("print.html"); | |
| 106 | + if !src.exists() { | |
| 107 | + eprintln!("✗ {} introuvable — lance build-all-langs.sh d'abord", src.display()); | |
| 108 | + continue; | |
| 109 | + } | |
| 110 | + let src_abs = fs::canonicalize(&src)?; | |
| 111 | + let out = pdf_dir.join(format!("documentation-{lang}.pdf")); | |
| 112 | + | |
| 113 | + println!("→ {lang} : {} → {}", src_abs.display(), out.display()); | |
| 114 | + | |
| 115 | + let ok = match &backend { | |
| 116 | + Backend::Wkhtmltopdf(b) => render_wkhtmltopdf(b, &src_abs, &out, lang)?, | |
| 117 | + Backend::Chromium(b) => render_chromium(b, &src_abs, &out, lang)?, | |
| 118 | + }; | |
| 119 | + if ok { | |
| 120 | + let kb = out.metadata()?.len() / 1024; | |
| 121 | + println!(" ✓ {} ({} KB)", out.display(), kb); | |
| 122 | + } else { | |
| 123 | + eprintln!(" ✗ backend a échoué pour {lang}"); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + println!("\n✓ Terminé."); | |
| 128 | + Ok(()) | |
| 129 | +} |
M
src/SUMMARY.md
+5
-0
@@ -50,6 +50,7 @@
| 50 | 50 | - [Configurer OAuth](administration_manual/how-to/configurer-oauth.md) |
| 51 | 51 | - [Configurer un runner CI distant](administration_manual/how-to/configurer-ci-runner-remote.md) |
| 52 | 52 | - [Intégrer Dependency-Track (SBOM)](administration_manual/how-to/integrer-dependency-track.md) |
| 53 | + - [Configurer ssh-guard (durcissement SSH)](administration_manual/how-to/configurer-ssh-guard.md) | |
| 53 | 54 | - [Durcir avec Fail2ban](administration_manual/how-to/durcir-avec-fail2ban.md) |
| 54 | 55 | - [Régler le rate limiting](administration_manual/how-to/tuner-rate-limiting.md) |
| 55 | 56 | - [Forcer le 2FA sur toute l'instance](administration_manual/how-to/forcer-2fa-globalement.md) |
@@ -59,6 +60,7 @@
| 59 | 60 | - [Auditer une instance](administration_manual/how-to/auditer-instance.md) |
| 60 | 61 | - [Référence](administration_manual/reference/index.md) |
| 61 | 62 | - [Variables d'environnement](administration_manual/reference/variables-environnement.md) |
| 63 | + - [Événements ssh-guard (JSON)](administration_manual/reference/ssh-guard-evenements.md) | |
| 62 | 64 | - [Paramètres dynamiques](administration_manual/reference/parametres-dynamiques.md) |
| 63 | 65 | - [Schéma de la base de données](administration_manual/reference/schema-base-de-donnees.md) |
| 64 | 66 | - [Ports et services](administration_manual/reference/ports-et-services.md) |
@@ -68,6 +70,7 @@
| 68 | 70 | - [Modèle de déploiement](administration_manual/explanation/modele-deploiement.md) |
| 69 | 71 | - [CI Dagger : Easy Mode vs Power Mode](administration_manual/explanation/ci-dagger.md) |
| 70 | 72 | - [Stratégie de sauvegarde](administration_manual/explanation/strategie-sauvegarde.md) |
| 73 | + - [ssh-guard : détection d'attaques SSH](administration_manual/explanation/ssh-guard-detection.md) | |
| 71 | 74 | |
| 72 | 75 | --- |
| 73 | 76 |
@@ -92,6 +95,7 @@
| 92 | 95 | - [Passer la QA avant un merge](developer_manual/how-to/passer-la-qa-avant-merge.md) |
| 93 | 96 | - [Référence](developer_manual/reference/index.md) |
| 94 | 97 | - [Architecture des crates](developer_manual/reference/architecture-crates.md) |
| 98 | + - [Crate gitrust-ssh-guard](developer_manual/reference/ssh-guard-crate.md) | |
| 95 | 99 | - [Services et API interne](developer_manual/reference/services-api-interne.md) |
| 96 | 100 | - [Schéma de la base de données](developer_manual/reference/schema-base-donnees.md) |
| 97 | 101 | - [Règles QA et conformité ANSSI](developer_manual/reference/regles-qa-anssi.md) |
@@ -107,6 +111,7 @@
| 107 | 111 | - [CI Dagger : Easy Mode vs Power Mode](developer_manual/explanation/ci-dagger-easy-vs-power.md) |
| 108 | 112 | - [Décisions UI (HTMX, DaisyUI)](developer_manual/explanation/decisions-ui-htmx.md) |
| 109 | 113 | - [Patron worker async](developer_manual/explanation/patron-worker-async.md) |
| 114 | + - [Conception de ssh-guard](developer_manual/explanation/ssh-guard-conception.md) | |
| 110 | 115 | |
| 111 | 116 | --- |
| 112 | 117 |
A
src/screenshots
+1
-0
@@ -0,0 +1 @@
| 1 | +../screenshots | |
| 1 | < \ No newline at end of file |
A
template/env/ssh-guard-direct.env
+41
-0
@@ -0,0 +1,41 @@
| 1 | +# ============================================================================= | |
| 2 | +# ssh-guard — profil DIRECT (gitrust exposé Internet sans reverse-proxy SSH) | |
| 3 | +# ============================================================================= | |
| 4 | +# À ajouter à /opt/gitrust/.env. Voir : | |
| 5 | +# administration_manual/how-to/configurer-ssh-guard.md | |
| 6 | +# administration_manual/reference/variables-environnement.md (section 22) | |
| 7 | + | |
| 8 | +# --- Kill switch --- | |
| 9 | +SSH_GUARD_ENABLED=true | |
| 10 | +SSH_GUARD_DRY_RUN=false | |
| 11 | + | |
| 12 | +# --- Profil de déploiement --- | |
| 13 | +# direct : pas de proxy devant gitrust, peer_addr est l'IP réelle du client | |
| 14 | +SSH_GUARD_PROFILE=direct | |
| 15 | + | |
| 16 | +# --- PROXY protocol (désactivé en profil direct) --- | |
| 17 | +SSH_GUARD_PROXY_PROTOCOL=disabled | |
| 18 | + | |
| 19 | +# --- Détecteurs (seuils par défaut, ajustables) --- | |
| 20 | +SSH_GUARD_BRUTE_FORCE_THRESHOLD=5 | |
| 21 | +SSH_GUARD_BRUTE_FORCE_WINDOW_SECS=300 | |
| 22 | +SSH_GUARD_USER_ENUM_THRESHOLD=10 | |
| 23 | +SSH_GUARD_USER_ENUM_WINDOW_SECS=300 | |
| 24 | +SSH_GUARD_KEY_SCAN_THRESHOLD=10 | |
| 25 | +SSH_GUARD_KEY_SCAN_WINDOW_SECS=300 | |
| 26 | +SSH_GUARD_CONN_FLOOD_PER_SEC=10 | |
| 27 | +SSH_GUARD_CONN_FLOOD_BURST=20 | |
| 28 | + | |
| 29 | +# --- Ban : TTL 1h par défaut, 0 = permanent --- | |
| 30 | +SSH_GUARD_AUTO_BAN_DURATION_SECS=3600 | |
| 31 | + | |
| 32 | +# --- Stockage hybride (RAM chaude + write-through PostgreSQL) --- | |
| 33 | +SSH_GUARD_STORE_BACKEND=hybrid | |
| 34 | +SSH_GUARD_STORE_FLUSH_INTERVAL_MS=1000 | |
| 35 | +SSH_GUARD_EVENTS_RETENTION_DAYS=90 | |
| 36 | + | |
| 37 | +# --- Observabilité : JSON dans journald + fichier dédié pour fail2ban --- | |
| 38 | +SSH_GUARD_LOG_FORMAT=json | |
| 39 | +SSH_GUARD_LOG_TARGET=both | |
| 40 | +SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 41 | +SSH_GUARD_METRICS_ENABLED=true |
A
template/env/ssh-guard-nginx.env
+61
-0
@@ -0,0 +1,61 @@
| 1 | +# ============================================================================= | |
| 2 | +# ssh-guard — profil NGINX (gitrust derrière nginx stream + PROXY protocol v2) | |
| 3 | +# ============================================================================= | |
| 4 | +# À ajouter à /opt/gitrust/.env. Voir : | |
| 5 | +# administration_manual/how-to/configurer-ssh-guard.md | |
| 6 | +# administration_manual/reference/variables-environnement.md (section 22) | |
| 7 | +# | |
| 8 | +# Topologie attendue : nginx termine TLS sur :443 et fait du SSH stream | |
| 9 | +# sur :22 → 127.0.0.1:2222 avec proxy_protocol on; côté nginx. | |
| 10 | +# | |
| 11 | +# Rappel nginx.conf : | |
| 12 | +# stream { | |
| 13 | +# upstream gitrust_ssh { server 127.0.0.1:2222; } | |
| 14 | +# server { | |
| 15 | +# listen 22; | |
| 16 | +# proxy_pass gitrust_ssh; | |
| 17 | +# proxy_protocol on; # IMPORTANT | |
| 18 | +# proxy_timeout 30m; # gros push possible | |
| 19 | +# } | |
| 20 | +# } | |
| 21 | + | |
| 22 | +# --- Binding gitrust : ne pas exposer SSH directement --- | |
| 23 | +SSH_LISTEN_ADDR=127.0.0.1 | |
| 24 | +SSH_PORT=2222 | |
| 25 | + | |
| 26 | +# --- Kill switch ssh-guard --- | |
| 27 | +SSH_GUARD_ENABLED=true | |
| 28 | +SSH_GUARD_DRY_RUN=false | |
| 29 | + | |
| 30 | +# --- Profil nginx : pose v2 + strict + trusted_proxies localhost --- | |
| 31 | +SSH_GUARD_PROFILE=nginx | |
| 32 | + | |
| 33 | +# Le profil pose déjà ces valeurs (laissées explicites pour audit) : | |
| 34 | +SSH_GUARD_PROXY_PROTOCOL=v2 | |
| 35 | +SSH_GUARD_PROXY_PROTOCOL_STRICT=true | |
| 36 | +SSH_GUARD_PROXY_PROTOCOL_TIMEOUT_MS=3000 | |
| 37 | +SSH_GUARD_TRUSTED_PROXIES=127.0.0.1/32,::1/128 | |
| 38 | + | |
| 39 | +# --- Détecteurs (seuils par défaut, ajustables) --- | |
| 40 | +SSH_GUARD_BRUTE_FORCE_THRESHOLD=5 | |
| 41 | +SSH_GUARD_BRUTE_FORCE_WINDOW_SECS=300 | |
| 42 | +SSH_GUARD_USER_ENUM_THRESHOLD=10 | |
| 43 | +SSH_GUARD_USER_ENUM_WINDOW_SECS=300 | |
| 44 | +SSH_GUARD_KEY_SCAN_THRESHOLD=10 | |
| 45 | +SSH_GUARD_KEY_SCAN_WINDOW_SECS=300 | |
| 46 | +SSH_GUARD_CONN_FLOOD_PER_SEC=10 | |
| 47 | +SSH_GUARD_CONN_FLOOD_BURST=20 | |
| 48 | + | |
| 49 | +# --- Ban : TTL 1h par défaut --- | |
| 50 | +SSH_GUARD_AUTO_BAN_DURATION_SECS=3600 | |
| 51 | + | |
| 52 | +# --- Stockage hybride --- | |
| 53 | +SSH_GUARD_STORE_BACKEND=hybrid | |
| 54 | +SSH_GUARD_STORE_FLUSH_INTERVAL_MS=1000 | |
| 55 | +SSH_GUARD_EVENTS_RETENTION_DAYS=90 | |
| 56 | + | |
| 57 | +# --- Observabilité : JSON dans journald + fichier dédié pour fail2ban --- | |
| 58 | +SSH_GUARD_LOG_FORMAT=json | |
| 59 | +SSH_GUARD_LOG_TARGET=both | |
| 60 | +SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 61 | +SSH_GUARD_METRICS_ENABLED=true |
M
template/index.md
+10
-0
@@ -14,6 +14,8 @@ Bienvenue dans l'atelier gitrust. Chaque fichier ici est **standalone et copiabl
| 14 | 14 | | [`env/production-mono-machine.env`](env/production-mono-machine.env) | Production mono-machine derrière reverse-proxy — PostgreSQL, SMTP, Redis sessions, TLS délégué | |
| 15 | 15 | | [`env/production-ci-heavy.env`](env/production-ci-heavy.env) | Production avec CI runners distants intensifs — tuning pool de connexions, rate limiting ajusté | |
| 16 | 16 | | [`env/tests-e2e.env`](env/tests-e2e.env) | Environnement E2E Playwright — base éphémère, seed de données, SMTP mock | |
| 17 | +| [`env/ssh-guard-direct.env`](env/ssh-guard-direct.env) | Bloc `SSH_GUARD_*` pour gitrust exposé Internet sans reverse-proxy SSH (profil `direct`) | | |
| 18 | +| [`env/ssh-guard-nginx.env`](env/ssh-guard-nginx.env) | Bloc `SSH_GUARD_*` pour gitrust derrière nginx stream avec PROXY protocol v2 (profil `nginx`) | | |
| 17 | 19 | |
| 18 | 20 | --- |
| 19 | 21 |
@@ -60,6 +62,14 @@ Bienvenue dans l'atelier gitrust. Chaque fichier ici est **standalone et copiabl
| 60 | 62 | |
| 61 | 63 | --- |
| 62 | 64 | |
| 65 | +## Sécurité (`security/`) | |
| 66 | + | |
| 67 | +| Fichier | Cas d'usage | | |
| 68 | +|---------|-------------| | |
| 69 | +| [`security/fail2ban-gitrust-ssh-guard.conf`](security/fail2ban-gitrust-ssh-guard.conf) | Jail + filtre fail2ban consommant le flux JSON stable de `gitrust-ssh-guard` (durcissement SSH) | | |
| 70 | + | |
| 71 | +--- | |
| 72 | + | |
| 63 | 73 | ## Scripts de sauvegarde et restauration (`backup/`) |
| 64 | 74 | |
| 65 | 75 | | Fichier | Cas d'usage | |
A
template/security/fail2ban-gitrust-ssh-guard.conf
+50
-0
@@ -0,0 +1,50 @@
| 1 | +# ============================================================================= | |
| 2 | +# fail2ban — jail + filtre prêts à l'emploi pour le flux JSON ssh-guard | |
| 3 | +# ============================================================================= | |
| 4 | +# Ce fichier contient deux blocs distincts à placer à des emplacements | |
| 5 | +# différents sur l'hôte fail2ban. Voir aussi : | |
| 6 | +# administration_manual/how-to/durcir-avec-fail2ban.md | |
| 7 | +# administration_manual/reference/ssh-guard-evenements.md | |
| 8 | +# | |
| 9 | +# Prérequis dans /opt/gitrust/.env : | |
| 10 | +# SSH_GUARD_LOG_TARGET=both (ou file) | |
| 11 | +# SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json | |
| 12 | +# | |
| 13 | +# Tester le filtre avant de redémarrer fail2ban : | |
| 14 | +# sudo fail2ban-regex /var/log/gitrust-ssh-guard.json \ | |
| 15 | +# /etc/fail2ban/filter.d/gitrust-ssh.conf | |
| 16 | + | |
| 17 | +# ----------------------------------------------------------------------------- | |
| 18 | +# 1) JAIL — à concaténer dans /etc/fail2ban/jail.local sous [DEFAULT] | |
| 19 | +# ----------------------------------------------------------------------------- | |
| 20 | +# [gitrust-ssh] | |
| 21 | +# enabled = true | |
| 22 | +# port = 22,2222 | |
| 23 | +# filter = gitrust-ssh | |
| 24 | +# logpath = /var/log/gitrust-ssh-guard.json | |
| 25 | +# maxretry = 1 | |
| 26 | +# findtime = 10m | |
| 27 | +# bantime = 1h | |
| 28 | + | |
| 29 | +# ----------------------------------------------------------------------------- | |
| 30 | +# 2) FILTRE — à placer dans /etc/fail2ban/filter.d/gitrust-ssh.conf | |
| 31 | +# ----------------------------------------------------------------------------- | |
| 32 | +[Definition] | |
| 33 | + | |
| 34 | +# Capture <HOST> depuis le champ "ip" du JSON émis par gitrust-ssh-guard. | |
| 35 | +# Le « signal fort » ip_banned est privilégié : ssh-guard a déjà corrélé | |
| 36 | +# brute-force / énumération / scan de clés ; fail2ban relaye le ban au | |
| 37 | +# firewall (UFW/iptables) pour bloquer aussi les autres ports si la jail | |
| 38 | +# le souhaite (banaction = ufw, banaction_allports = ufw). | |
| 39 | + | |
| 40 | +failregex = ^.*"event":"ip_banned".*"ip":"<HOST>".*$ | |
| 41 | + ^.*"event":"brute_force_detected".*"ip":"<HOST>".*$ | |
| 42 | + ^.*"event":"user_enumeration_detected".*"ip":"<HOST>".*$ | |
| 43 | + ^.*"event":"key_scanning_detected".*"ip":"<HOST>".*$ | |
| 44 | + ^.*"event":"connection_dropped".*"ip":"<HOST>".*"reason":"untrusted_proxy".*$ | |
| 45 | + ^.*"event":"connection_dropped".*"ip":"<HOST>".*"reason":"proxy_header_invalid".*$ | |
| 46 | + | |
| 47 | +ignoreregex = | |
| 48 | + | |
| 49 | +# Date au format ISO 8601 UTC produit par ssh-guard (champ "ts") | |
| 50 | +datepattern = "ts":"%%Y-%%m-%%dT%%H:%%M:%%S |
A
theme/back-to-landing.js
+49
-0
@@ -0,0 +1,49 @@
| 1 | +// Injecte un lien "← gitrust.eu" dans la menu-bar mdBook quand la doc est | |
| 2 | +// servie sous un sous-chemin /<lang>/docs/ de la landing gitrust.eu. | |
| 3 | +// | |
| 4 | +// Détection : | |
| 5 | +// - location.pathname commence par /<lang>/docs/ | |
| 6 | +// - location.hostname se termine par "gitrust.eu" (ou env staging.*gitrust.eu) | |
| 7 | +// | |
| 8 | +// En dev local (localhost, file://), ne fait rien. | |
| 9 | + | |
| 10 | +(function () { | |
| 11 | + "use strict"; | |
| 12 | + | |
| 13 | + function init() { | |
| 14 | + var path = window.location.pathname || ""; | |
| 15 | + var host = window.location.hostname || ""; | |
| 16 | + | |
| 17 | + var m = path.match(/^\/([a-z]{2})\/docs\//); | |
| 18 | + if (!m) return; | |
| 19 | + if (!/gitrust\.(eu|nuage\.ebii)(:\d+)?$/.test(host)) return; | |
| 20 | + | |
| 21 | + var lang = m[1]; | |
| 22 | + var menuBar = document.querySelector("#menu-bar, .menu-bar"); | |
| 23 | + if (!menuBar) return; | |
| 24 | + | |
| 25 | + var a = document.createElement("a"); | |
| 26 | + a.href = "/" + lang + "/"; | |
| 27 | + a.className = "gitrust-back-to-landing icon-button"; | |
| 28 | + a.setAttribute("aria-label", "Retour au site principal gitrust.eu"); | |
| 29 | + a.innerHTML = | |
| 30 | + '<span aria-hidden="true" style="font-size:1.1em;padding-right:.35em;">←</span>' + | |
| 31 | + '<span>gitrust.eu</span>'; | |
| 32 | + | |
| 33 | + // Style inline : simple et robuste, ne dépend pas du thème | |
| 34 | + a.style.cssText = | |
| 35 | + "display:inline-flex;align-items:center;gap:0;padding:0 .75em;" + | |
| 36 | + "margin-left:.4em;height:2.1rem;font-weight:600;font-size:.95rem;" + | |
| 37 | + "color:var(--fg);text-decoration:none;border-right:1px solid rgba(43,43,60,0.12);" + | |
| 38 | + "white-space:nowrap;"; | |
| 39 | + | |
| 40 | + // Insérer en premier (à gauche de l'icône sidebar-toggle) | |
| 41 | + menuBar.insertBefore(a, menuBar.firstChild); | |
| 42 | + } | |
| 43 | + | |
| 44 | + if (document.readyState === "loading") { | |
| 45 | + document.addEventListener("DOMContentLoaded", init); | |
| 46 | + } else { | |
| 47 | + init(); | |
| 48 | + } | |
| 49 | +})(); |
M
theme/gitrust-branding.css
+22
-5
@@ -231,11 +231,28 @@ code, pre, .hljs, .mono, kbd {
| 231 | 231 | } |
| 232 | 232 | |
| 233 | 233 | .sidebar .part-title { |
| 234 | - text-transform: uppercase; | |
| 235 | - letter-spacing: 0.08em; | |
| 236 | - font-size: 0.75rem; | |
| 237 | - color: var(--gitrust-ink-mute); | |
| 238 | - padding-top: 1.25em; | |
| 234 | + display: block; | |
| 235 | + margin: 1.5em 0.5em 0.6em 0.5em; | |
| 236 | + padding: 0.7em 1em; | |
| 237 | + font-size: 1.25rem; | |
| 238 | + font-weight: 700; | |
| 239 | + letter-spacing: 0.01em; | |
| 240 | + text-transform: none; | |
| 241 | + text-align: center; | |
| 242 | + color: #fff; | |
| 243 | + background: linear-gradient(135deg, var(--gitrust-rust) 0%, var(--gitrust-rust-hover) 100%); | |
| 244 | + border-radius: 0.4rem; | |
| 245 | + box-shadow: 0 2px 8px rgba(183, 65, 14, 0.25); | |
| 246 | +} | |
| 247 | +.sidebar .part-title:first-child { | |
| 248 | + margin-top: 0.5em; | |
| 249 | +} | |
| 250 | +.ayu .sidebar .part-title, | |
| 251 | +.navy .sidebar .part-title, | |
| 252 | +.coal .sidebar .part-title { | |
| 253 | + background: linear-gradient(135deg, #ff8e3c 0%, #d4501a 100%); | |
| 254 | + color: #1c1b22; | |
| 255 | + box-shadow: 0 2px 8px rgba(255, 142, 60, 0.35); | |
| 239 | 256 | } |
| 240 | 257 | |
| 241 | 258 | /* =================================================================== |
GitRust