commit b968ec8aa55624dd7ac5761a993ad339c1d4dbad Author: Fabien Zarifian Date: Tue May 5 13:52:13 2026 +0200 feat: implémentation initiale du reverse proxy de maintenance Reverse proxy Nginx (image stable-alpine) qui sert l'app upstream ou une page de maintenance 503 selon l'IP du client. - Modes whitelist/blacklist commutables via MAINTENANCE_MODE - Liste IPv4 via MAINTENANCE_IP_LIST (séparée par virgules, validée au boot) - Logique en directives Nginx natives (geo + map), zéro Lua/njs - Page statique sobre HTML+CSS inline, zéro JS, zéro réseau sortant - Log dédié /var/log/nginx/maintenance.log pour les requêtes bloquées - Validation des env vars dans /docker-entrypoint.d/10-init.sh - Stack Docker + docker-compose pour dev local et tests - 6 cas de tests d'intégration (whitelist/blacklist x autorisée/bloquée + log + nginx -t) - Lint shellcheck propre sur tous les scripts shell diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7328611 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.dockerignore +.editorconfig +*.md +docker-compose*.yml +tests/ +.env +.env.local +.idea/ +.vscode/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..57574d2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,md}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bace05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.log +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +nginx/snippets/_generated_*.conf +.env +.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..158f712 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +Tous les changements notables de ce projet sont documentés dans ce fichier. + +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Reverse proxy Nginx avec page de maintenance conditionnelle par IP (mode whitelist/blacklist). +- Configuration via variables d'environnement : `MAINTENANCE_MODE`, `MAINTENANCE_IP_LIST`, `UPSTREAM_HOST`, `LISTEN_PORT`, `SERVER_NAME`. +- Page de maintenance HTML statique sobre, sans JS, sans dépendance réseau. +- Log dédié `maintenance.log` pour les requêtes bloquées. +- Validation des env vars et de la conf Nginx au démarrage du conteneur. +- Tests d'intégration shell (whitelist autorisée/bloquée, blacklist bloquée/normale, log dédié, syntaxe Nginx). +- Lint shellcheck + `nginx -t`. +- Packaging Docker + docker-compose pour le dev local et les tests. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7b9c3af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM nginx:stable-alpine + +# Limite envsubst aux seules variables explicitement utilisées dans le template, +# pour préserver les variables Nginx natives ($remote_addr, $host, ...). +ENV NGINX_ENVSUBST_FILTER='^(LISTEN_PORT|SERVER_NAME|UPSTREAM_HOST|MAINTENANCE_MODE)$' + +# Le snippet de log est statique et copié une fois pour toutes. +COPY nginx/snippets/maintenance-log.conf /etc/nginx/snippets/maintenance-log.conf + +# Le template est traité par /docker-entrypoint.d/20-envsubst-on-templates.sh +# fourni par l'image officielle, après notre hook 10-init.sh. +COPY nginx/templates/default.conf.template /etc/nginx/templates/default.conf.template + +# Page de maintenance statique. +COPY public/maintenance.html /usr/share/nginx/html/maintenance.html + +# Scripts utilitaires + hook d'initialisation. +COPY scripts/build-ip-list.sh /usr/local/bin/build-ip-list.sh +COPY scripts/entrypoint.sh /docker-entrypoint.d/10-init.sh +RUN chmod +x /usr/local/bin/build-ip-list.sh /docker-entrypoint.d/10-init.sh + +# Crée le fichier maintenance.log à l'avance (sinon Nginx ne logge pas tant +# que le fichier n'existe pas) et délègue les logs standards à stdout/stderr. +RUN ln -sf /dev/stdout /var/log/nginx/access.log \ + && ln -sf /dev/stderr /var/log/nginx/error.log \ + && touch /var/log/nginx/maintenance.log + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- "http://127.0.0.1:${LISTEN_PORT:-8080}/" >/dev/null 2>&1 || exit 1 diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..a68d2bf --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,322 @@ +# PROMPT.md — Reverse Proxy Nginx avec Page de Maintenance Conditionnelle par IP + +> Document de spécification autonome du projet. Toute personne (humaine ou agent IA) peut s'y référer pour comprendre, implémenter et maintenir le projet sans contexte externe. + +--- + +## 1. Vision du projet + +Construire un **reverse proxy Nginx** qui, selon l'adresse IP du client, **redirige les requêtes vers une application locale** (proxy_pass) **ou affiche une page de maintenance statique** avec un code HTTP `503 Service Unavailable`. Le projet est packagé en Docker, agnostique de l'application backend, et la logique (mode whitelist / blacklist + liste d'IP + upstream) est entièrement pilotée par variables d'environnement au démarrage du conteneur. + +Cas d'usage typique : exposer une application en pré-production à une équipe interne uniquement, ou bloquer ponctuellement quelques IP indésirables pendant qu'on travaille sur un correctif. + +--- + +## 2. Stack technique + +| Composant | Choix | Confiance | Justification | +|---|---|---|---| +| Reverse proxy | **Nginx** (image `nginx:stable-alpine`) | 🟢 élevée | Standard de l'industrie, déterministe, configuration purement déclarative. | +| Logique conditionnelle | Directives natives `geo` + `map` + `if` (pas de Lua, pas de njs) | 🟢 élevée | Couvre 100 % du besoin sans dépendance tierce. Évite les modules dynamiques. | +| Templating de config | `envsubst` (depuis `gettext`) appelé par l'entrypoint Nginx officiel via `/etc/nginx/templates/` | 🟢 élevée | Mécanisme officiel de l'image Nginx, zéro magie, pas de runtime supplémentaire. | +| Page de maintenance | HTML + CSS inline, system font stack, icône SVG inline, **zéro JavaScript** | 🟢 élevée | Sert même si tout est cassé en aval. Pas de dépendance réseau (pas de Google Fonts, pas de CDN). | +| Packaging | **Docker** + **docker-compose** (pour le dev local et la démo) | 🟢 élevée | Reproductible, mêmes commandes en local et en prod. | +| Entrypoint | Script **bash** (validation des env vars + `nginx -t`) | 🟢 élevée | Échec rapide au boot si conf invalide, plutôt qu'un 502 silencieux. | +| Tests | Scripts **bash + curl** lancés contre un conteneur de test (avec un upstream simulé en `nginx:alpine` ou `python -m http.server`) | 🟢 élevée | Boîte noire = vérité terrain. Pas de framework de test à apprendre. | +| Lint shell | **shellcheck** sur tous les `.sh` | 🟢 élevée | Outil standard, signaux nets. | +| Validation conf | **`nginx -t`** systématique avant tout commit (pre-commit local) | 🟢 élevée | Coût zéro, attrape 90 % des bugs. | + +**Aucune techno 🟡 ou 🔵 dans ce projet.** Toutes les briques sont dans la zone de fiabilité maximale d'un agent comme moi : Nginx natif, bash POSIX, Docker, curl. Aucune librairie de niche. + +--- + +## 3. Architecture et structure des dossiers + +``` +appli_test/ +├── PROMPT.md # Ce fichier — source de vérité du projet +├── README.md # Quickstart : comment lancer, comment configurer +├── CHANGELOG.md # Conventional Changelog +├── .editorconfig # Cohérence indentation entre éditeurs +├── .gitignore +├── .dockerignore +├── Dockerfile # Image basée sur nginx:stable-alpine +├── docker-compose.yml # Stack de dev : proxy + upstream factice +├── docker-compose.test.yml # Stack de tests d'intégration +│ +├── nginx/ +│ ├── templates/ +│ │ └── default.conf.template # Template traité par envsubst au boot +│ └── snippets/ +│ └── maintenance-log.conf # Format de log dédié maintenance.log +│ +├── public/ +│ └── maintenance.html # Page statique servie en 503 (HTML+CSS inline) +│ +├── scripts/ +│ ├── entrypoint.sh # Validation env + génération conf + nginx -t + exec nginx +│ ├── build-ip-list.sh # Transforme MAINTENANCE_IP_LIST="a,b,c" en directives geo +│ └── lint.sh # Lance shellcheck + nginx -t +│ +└── tests/ + ├── run.sh # Orchestrateur : up compose.test, attend, lance les cas, tear down + ├── cases/ + │ ├── whitelist_authorized_ip.sh # IP listée → 200 (proxy OK) + │ ├── whitelist_unauthorized_ip.sh # IP non listée → 503 + page maintenance + │ ├── blacklist_blocked_ip.sh # IP listée → 503 + │ ├── blacklist_normal_ip.sh # IP non listée → 200 + │ ├── log_dedicated.sh # Une requête bloquée apparaît dans maintenance.log + │ └── nginx_syntax.sh # `nginx -t` retourne 0 + └── fixtures/ + └── upstream/ # Petit serveur factice pour simuler l'app cible +``` + +**Pourquoi cette structure ?** +- `nginx/templates/` est le chemin que l'entrypoint officiel de l'image Nginx scanne automatiquement pour `envsubst`. On suit la convention upstream. +- `public/` séparé du templating : la page statique n'a pas à passer par envsubst. +- `scripts/` regroupe tout le shell pour qu'il soit lintable et testable d'un coup. +- `tests/cases/` : un fichier `.sh` par scénario, lisible, débogable individuellement. + +--- + +## 4. Spécification fonctionnelle détaillée + +### 4.1 Variables d'environnement (contrat d'API) + +| Variable | Obligatoire | Exemple | Description | +|---|---|---|---| +| `MAINTENANCE_MODE` | ✅ | `whitelist` ou `blacklist` | Sémantique de la liste. `whitelist` = seules les IP listées passent, le reste voit la maintenance. `blacklist` = inverse. | +| `MAINTENANCE_IP_LIST` | ✅ | `81.92.47.8,10.0.0.42,192.168.1.5` | Liste d'IP **IPv4 individuelles** séparées par virgules. Espaces tolérés autour des virgules. Pas de CIDR, pas d'IPv6 (out of scope MVP). | +| `UPSTREAM_HOST` | ✅ | `127.0.0.1:3000` | Hôte:port de l'application backend. Le proxy fait `proxy_pass http://$UPSTREAM_HOST`. | +| `LISTEN_PORT` | ❌ (défaut `8080`) | `80` | Port d'écoute du conteneur. | +| `SERVER_NAME` | ❌ (défaut `_`) | `app.example.com` | Valeur de `server_name` dans le bloc Nginx. | + +### 4.2 Comportement + +- **Topologie** : Nginx est **directement exposé** au client. L'IP source = `$remote_addr`. Aucune confiance accordée à `X-Forwarded-For` reçu de l'extérieur. +- **Code HTTP** : la page de maintenance est servie avec **`503 Service Unavailable`** (et un en-tête `Retry-After: 3600` indicatif). +- **Scope** : la maintenance s'applique à **tout le domaine** — chaque path, chaque méthode, y compris assets et appels API. Aucune exclusion (pas de healthcheck particulier, pas d'exception assets). +- **Logging** : + - `access.log` standard pour toutes les requêtes (format combined). + - `maintenance.log` **dédié** : ne contient **que** les requêtes ayant abouti sur la page de maintenance. Format : `$time_iso8601 $remote_addr "$request" "$http_user_agent"`. +- **Pas de bypass token** : le seul critère discriminant est l'IP. Pas de cookie magique, pas de header de contournement. +- **Validation au démarrage** : si `MAINTENANCE_MODE` n'est ni `whitelist` ni `blacklist`, ou si `MAINTENANCE_IP_LIST` est vide, ou si `UPSTREAM_HOST` est absent, l'entrypoint **échoue immédiatement** avec un message clair sur stderr et exit code 1. Pas de fallback silencieux. + +### 4.3 Logique Nginx (cœur du projet) + +Le mécanisme repose sur deux directives : + +```nginx +# Construit dynamiquement par envsubst depuis MAINTENANCE_IP_LIST +geo $ip_in_list { + default 0; + 81.92.47.8 1; + 10.0.0.42 1; + # ... une ligne par IP, générée au boot +} + +# MAINTENANCE_MODE = whitelist → on bloque si pas dans la liste (ip_in_list=0) +# MAINTENANCE_MODE = blacklist → on bloque si dans la liste (ip_in_list=1) +map "$maintenance_mode:$ip_in_list" $is_in_maintenance { + default 0; + "whitelist:0" 1; + "blacklist:1" 1; +} +``` + +L'astuce : `$maintenance_mode` est lui-même injecté par `envsubst`, et `MAINTENANCE_IP_LIST` est transformée en lignes `geo` par `scripts/build-ip-list.sh` avant que Nginx ne démarre. + +--- + +## 5. Conventions de codage + +### 5.1 Nginx + +- **Indentation** : 4 espaces, jamais de tabulations. +- **Une directive par ligne**, accolades sur leur propre ligne pour les blocs longs. +- **Commentaires** uniquement quand le *pourquoi* n'est pas évident depuis la lecture. Jamais de commentaire qui paraphrase la directive. +- **Pas de `if` dans les blocs `location`** sauf cas spécifiquement supportés (cf. [If Is Evil](https://nginx.org/en/docs/faq/if_is_evil.html)). On préfère `map` + `try_files` + `return`. +- Toute conf doit passer `nginx -t` ; aucune exception. + +### 5.2 Bash + +- **Shebang** `#!/usr/bin/env bash` partout. +- `set -euo pipefail` en première ligne après le shebang. +- **Toutes les variables entre guillemets** : `"$VAR"`, `"${ARR[@]}"`. +- Vérification d'arguments en début de script avec message d'usage. +- Tous les scripts `.sh` doivent passer `shellcheck` sans warning. + +### 5.3 HTML/CSS (page de maintenance) + +- **Un seul fichier** `public/maintenance.html` autonome. +- CSS **inline dans ` + + +
+ +

Site en maintenance

+

Nous effectuons actuellement une opération de maintenance sur le service.

+

Le site sera de nouveau accessible sous peu.

+

Merci de votre patience.

+
+ + diff --git a/scripts/build-ip-list.sh b/scripts/build-ip-list.sh new file mode 100755 index 0000000..00ac03d --- /dev/null +++ b/scripts/build-ip-list.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Transforme MAINTENANCE_IP_LIST="1.2.3.4, 5.6.7.8" en un snippet Nginx : +# +# geo $ip_in_list { +# default 0; +# 1.2.3.4 1; +# 5.6.7.8 1; +# } +# +# Toute IP malformée (non IPv4 valide) provoque une sortie en erreur. +# Usage : build-ip-list.sh + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +output_file="$1" + +if [[ -z "${MAINTENANCE_IP_LIST:-}" ]]; then + echo "ERROR: MAINTENANCE_IP_LIST est vide ou non défini." >&2 + exit 1 +fi + +# Regex IPv4 stricte : chaque octet entre 0 et 255. +ipv4_regex='^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$' + +declare -a ips=() + +# Découpe sur la virgule, trim les espaces, ignore les entrées vides. +IFS=',' read -ra raw_entries <<< "$MAINTENANCE_IP_LIST" +for raw in "${raw_entries[@]}"; do + ip="${raw// /}" + [[ -z "$ip" ]] && continue + if ! [[ "$ip" =~ $ipv4_regex ]]; then + echo "ERROR: IP invalide dans MAINTENANCE_IP_LIST : '$ip'" >&2 + exit 1 + fi + ips+=("$ip") +done + +if [[ ${#ips[@]} -eq 0 ]]; then + echo "ERROR: MAINTENANCE_IP_LIST ne contient aucune IP exploitable." >&2 + exit 1 +fi + +{ + echo "# Généré automatiquement par scripts/build-ip-list.sh — ne pas éditer à la main." + echo "geo \$ip_in_list {" + echo " default 0;" + for ip in "${ips[@]}"; do + printf ' %-15s 1;\n' "$ip" + done + echo "}" +} > "$output_file" + +echo "build-ip-list: ${#ips[@]} IP écrites dans $output_file" >&2 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..278a5e2 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Hook d'initialisation exécuté par l'entrypoint officiel de l'image +# nginx:stable-alpine, AVANT le script de templating envsubst (20-envsubst-on-templates.sh). +# +# Responsabilités : +# 1. Valider les variables d'environnement obligatoires (MAINTENANCE_MODE, +# MAINTENANCE_IP_LIST, UPSTREAM_HOST). Sortie 1 si invalide. +# 2. Appliquer les valeurs par défaut documentées (LISTEN_PORT=8080, SERVER_NAME=_). +# 3. Générer le snippet geo $ip_in_list via build-ip-list.sh. +# +# Le templating envsubst et le `nginx -t` final sont gérés par les scripts +# officiels qui s'exécutent ensuite. + +set -euo pipefail + +log() { echo "[init] $*" >&2; } +fail() { echo "[init] ERROR: $*" >&2; exit 1; } + +# --- 1. Validation des variables obligatoires --------------------------------- + +: "${MAINTENANCE_MODE:=}" +: "${MAINTENANCE_IP_LIST:=}" +: "${UPSTREAM_HOST:=}" + +case "$MAINTENANCE_MODE" in + whitelist|blacklist) ;; + "") fail "MAINTENANCE_MODE est requis (valeurs : 'whitelist' ou 'blacklist')." ;; + *) fail "MAINTENANCE_MODE='$MAINTENANCE_MODE' invalide (attendu : 'whitelist' ou 'blacklist')." ;; +esac + +if [[ -z "$MAINTENANCE_IP_LIST" ]]; then + fail "MAINTENANCE_IP_LIST est requis (liste d'IPv4 séparées par des virgules)." +fi + +if [[ -z "$UPSTREAM_HOST" ]]; then + fail "UPSTREAM_HOST est requis (ex: '127.0.0.1:3000')." +fi + +# Sanity check léger sur UPSTREAM_HOST : doit contenir un ':' (host:port). +if [[ "$UPSTREAM_HOST" != *:* ]]; then + fail "UPSTREAM_HOST='$UPSTREAM_HOST' doit être au format 'host:port'." +fi + +# --- 2. Valeurs par défaut --------------------------------------------------- + +export LISTEN_PORT="${LISTEN_PORT:-8080}" +export SERVER_NAME="${SERVER_NAME:-_}" +export MAINTENANCE_MODE +export UPSTREAM_HOST + +# --- 3. Génération du snippet geo -------------------------------------------- + +snippets_dir="/etc/nginx/snippets" +mkdir -p "$snippets_dir" + +# Chemin fixé par le Dockerfile (COPY ... /usr/local/bin/build-ip-list.sh). +# Surchargable via $BUILD_IP_LIST_SCRIPT pour le développement local hors conteneur. +build_script="${BUILD_IP_LIST_SCRIPT:-/usr/local/bin/build-ip-list.sh}" +if [[ ! -x "$build_script" ]]; then + fail "Script attendu introuvable ou non exécutable : $build_script" +fi + +"$build_script" "$snippets_dir/_generated_geo.conf" + +log "Initialisation OK (mode=$MAINTENANCE_MODE, listen=$LISTEN_PORT, upstream=$UPSTREAM_HOST)." diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..e3d7962 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Vérifie la qualité du projet : +# 1. shellcheck sur tous les scripts shell. +# 2. nginx -t sur la conf templatée (via un conteneur jetable). + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_ROOT" + +shell_files=( + scripts/build-ip-list.sh + scripts/entrypoint.sh + scripts/lint.sh + tests/run.sh + tests/lib.sh + tests/cases/whitelist_authorized_ip.sh + tests/cases/whitelist_unauthorized_ip.sh + tests/cases/blacklist_blocked_ip.sh + tests/cases/blacklist_normal_ip.sh + tests/cases/log_dedicated.sh + tests/cases/nginx_syntax.sh +) + +echo "==> shellcheck" +if command -v shellcheck >/dev/null 2>&1; then + shellcheck -x "${shell_files[@]}" +else + # Fallback : shellcheck via Docker si l'outil n'est pas installé localement. + docker run --rm -v "$PROJECT_ROOT:/mnt" -w /mnt koalaman/shellcheck:stable -x "${shell_files[@]}" +fi +echo "shellcheck OK" + +echo "==> nginx -t (build d'image + check)" +docker build --quiet -t maintenance-proxy:lint . >/dev/null + +docker run --rm \ + -e MAINTENANCE_MODE=whitelist \ + -e MAINTENANCE_IP_LIST="1.2.3.4,5.6.7.8" \ + -e UPSTREAM_HOST="upstream:80" \ + -e LISTEN_PORT=8080 \ + -e SERVER_NAME=_ \ + --entrypoint /bin/sh \ + maintenance-proxy:lint \ + -c '/docker-entrypoint.sh nginx -t' +echo "nginx -t OK" diff --git a/tests/cases/blacklist_blocked_ip.sh b/tests/cases/blacklist_blocked_ip.sh new file mode 100755 index 0000000..bf59441 --- /dev/null +++ b/tests/cases/blacklist_blocked_ip.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mode blacklist + IP du client présente → page de maintenance (503). + +set -euo pipefail +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../lib.sh +source "$(dirname "$0")/../lib.sh" + +restart_proxy blacklist "172.28.5.50" + +status="$(curl_status)" +assert_eq "503" "$status" "code HTTP" + +body="$(curl_body)" +assert_contains "Site en maintenance" "$body" "corps de la page de maintenance" diff --git a/tests/cases/blacklist_normal_ip.sh b/tests/cases/blacklist_normal_ip.sh new file mode 100755 index 0000000..ae3cda8 --- /dev/null +++ b/tests/cases/blacklist_normal_ip.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mode blacklist + IP du client absente → l'app upstream est servie (200). + +set -euo pipefail +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../lib.sh +source "$(dirname "$0")/../lib.sh" + +restart_proxy blacklist "10.99.99.99" + +status="$(curl_status)" +assert_eq "200" "$status" "code HTTP" + +body="$(curl_body)" +assert_contains "UPSTREAM_OK" "$body" "corps de la réponse" diff --git a/tests/cases/log_dedicated.sh b/tests/cases/log_dedicated.sh new file mode 100755 index 0000000..ba70957 --- /dev/null +++ b/tests/cases/log_dedicated.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Une requête bloquée doit apparaître dans /var/log/nginx/maintenance.log +# avec l'IP du client, et NE DOIT PAS y apparaître en mode passant. + +set -euo pipefail +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../lib.sh +source "$(dirname "$0")/../lib.sh" + +# Scénario bloqué (whitelist sans l'IP du client) +restart_proxy whitelist "10.99.99.99" + +# Vide le log avant le test pour partir d'un état connu +proxy_exec sh -c ': > /var/log/nginx/maintenance.log' + +# Génère une requête bloquée +status="$(curl_status)" +assert_eq "503" "$status" "code HTTP attendu" + +# Petit délai pour laisser Nginx flusher le log +sleep 1 + +log_content="$(proxy_exec cat /var/log/nginx/maintenance.log)" +assert_contains "172.28.5.50" "$log_content" "IP du client dans maintenance.log" + +# Vérifie qu'une requête passante (mode blacklist sans IP du client) ne logge PAS +restart_proxy blacklist "10.99.99.99" +proxy_exec sh -c ': > /var/log/nginx/maintenance.log' + +status="$(curl_status)" +assert_eq "200" "$status" "code HTTP en mode passant" + +sleep 1 +log_after_pass="$(proxy_exec cat /var/log/nginx/maintenance.log)" +if [[ -n "$log_after_pass" ]]; then + t_fail "maintenance.log devrait être vide après une requête non bloquée, contient : $log_after_pass" +fi diff --git a/tests/cases/nginx_syntax.sh b/tests/cases/nginx_syntax.sh new file mode 100755 index 0000000..208084d --- /dev/null +++ b/tests/cases/nginx_syntax.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Vérifie que la configuration Nginx complète passe `nginx -t` une fois +# templatée et le snippet geo généré. Ne dépend pas du proxy "live" : +# lance un conteneur jetable. + +set -euo pipefail +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../lib.sh +source "$(dirname "$0")/../lib.sh" + +docker run --rm \ + -e MAINTENANCE_MODE=whitelist \ + -e MAINTENANCE_IP_LIST="172.28.5.50,10.0.0.42" \ + -e UPSTREAM_HOST="upstream:80" \ + -e LISTEN_PORT=8080 \ + -e SERVER_NAME=_ \ + --entrypoint /bin/sh \ + maintenance-proxy:test \ + -c '/docker-entrypoint.sh nginx -t' >/dev/null 2>&1 \ + || t_fail "nginx -t a échoué sur la configuration générée" diff --git a/tests/cases/whitelist_authorized_ip.sh b/tests/cases/whitelist_authorized_ip.sh new file mode 100755 index 0000000..81e32b6 --- /dev/null +++ b/tests/cases/whitelist_authorized_ip.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mode whitelist + IP du client présente → l'app upstream est servie (200). + +set -euo pipefail +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../lib.sh +source "$(dirname "$0")/../lib.sh" + +restart_proxy whitelist "172.28.5.50" + +status="$(curl_status)" +assert_eq "200" "$status" "code HTTP" + +body="$(curl_body)" +assert_contains "UPSTREAM_OK" "$body" "corps de la réponse" diff --git a/tests/cases/whitelist_unauthorized_ip.sh b/tests/cases/whitelist_unauthorized_ip.sh new file mode 100755 index 0000000..863365b --- /dev/null +++ b/tests/cases/whitelist_unauthorized_ip.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mode whitelist + IP du client absente → page de maintenance (503). + +set -euo pipefail +# shellcheck source-path=SCRIPTDIR +# shellcheck source=../lib.sh +source "$(dirname "$0")/../lib.sh" + +restart_proxy whitelist "10.99.99.99" + +status="$(curl_status)" +assert_eq "503" "$status" "code HTTP" + +body="$(curl_body)" +assert_contains "Site en maintenance" "$body" "corps de la page de maintenance" diff --git a/tests/fixtures/upstream/index.html b/tests/fixtures/upstream/index.html new file mode 100644 index 0000000..87bccdf --- /dev/null +++ b/tests/fixtures/upstream/index.html @@ -0,0 +1,5 @@ + + +Upstream OK +

UPSTREAM_OK

+ diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..a81323a --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Helpers partagés par tests/run.sh et tests/cases/*.sh. +# Ce fichier est sourcé, pas exécuté directement. + +# shellcheck shell=bash + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml" +COMPOSE=(docker compose -f "$COMPOSE_FILE" -p maintenance_proxy_tests) + +PROXY_URL="http://172.28.5.10:8080/" + +t_log() { echo "[test] $*"; } +t_fail() { echo "[test] FAIL: $*" >&2; return 1; } + +# Recrée le conteneur proxy avec les env vars passées, puis attend qu'il +# réponde sur PROXY_URL (n'importe quel code HTTP suffit, on veut juste +# qu'Nginx ait fini de booter). +restart_proxy() { + local mode="$1" + local ip_list="$2" + + MAINTENANCE_MODE="$mode" \ + MAINTENANCE_IP_LIST="$ip_list" \ + "${COMPOSE[@]}" up -d --force-recreate --no-deps proxy >/dev/null + + local _ + for _ in $(seq 1 30); do + if "${COMPOSE[@]}" exec -T client \ + curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$PROXY_URL" \ + | grep -qE '^(200|503)$'; then + return 0 + fi + sleep 1 + done + t_fail "le proxy n'a pas démarré dans les 30s (mode=$mode, list=$ip_list)" +} + +curl_status() { + "${COMPOSE[@]}" exec -T client \ + curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$PROXY_URL" +} + +curl_body() { + "${COMPOSE[@]}" exec -T client curl -s --max-time 5 "$PROXY_URL" +} + +proxy_exec() { + "${COMPOSE[@]}" exec -T proxy "$@" +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="${3:-valeur}" + if [[ "$expected" != "$actual" ]]; then + t_fail "$label : attendu '$expected', obtenu '$actual'" + fi +} + +assert_contains() { + local needle="$1" + local haystack="$2" + local label="${3:-contenu}" + if [[ "$haystack" != *"$needle"* ]]; then + t_fail "$label : '$needle' introuvable dans la sortie" + fi +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..666e0b9 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# Orchestrateur des tests d'intégration : +# 1. Build l'image proxy. +# 2. Démarre la stack docker-compose.test.yml (upstream + client + proxy initial). +# 3. Exécute chaque tests/cases/*.sh dans un sous-shell. +# 4. Tear down systématique en fin (succès ou échec). + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_ROOT" + +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +source "$PROJECT_ROOT/tests/lib.sh" + +cleanup() { + t_log "Tear down de la stack de tests..." + "${COMPOSE[@]}" down --remove-orphans --volumes >/dev/null 2>&1 || true +} +trap cleanup EXIT + +t_log "Build de l'image maintenance-proxy:test..." +"${COMPOSE[@]}" build --quiet proxy + +t_log "Démarrage initial de la stack..." +MAINTENANCE_MODE="whitelist" \ +MAINTENANCE_IP_LIST="172.28.5.50" \ + "${COMPOSE[@]}" up -d >/dev/null + +# Attente que le client ait fini d'installer curl (entrypoint contient apk add). +for _ in $(seq 1 30); do + if "${COMPOSE[@]}" exec -T client sh -c 'command -v curl' >/dev/null 2>&1; then + break + fi + sleep 1 +done +if ! "${COMPOSE[@]}" exec -T client sh -c 'command -v curl' >/dev/null 2>&1; then + echo "[test] FAIL: curl indisponible dans le conteneur client" >&2 + exit 1 +fi + +shopt -s nullglob +cases=("$PROJECT_ROOT/tests/cases/"*.sh) +if [[ ${#cases[@]} -eq 0 ]]; then + echo "[test] aucun cas trouvé dans tests/cases/" >&2 + exit 1 +fi + +passed=0 +failed=0 +failed_names=() + +for case_path in "${cases[@]}"; do + case_name="$(basename "$case_path" .sh)" + t_log "▶ $case_name" + if bash "$case_path"; then + t_log " ✓ OK" + passed=$((passed + 1)) + else + t_log " ✗ FAIL" + failed=$((failed + 1)) + failed_names+=("$case_name") + fi +done + +echo +echo "===============================================" +echo " Résultats : $passed OK / $failed FAIL / $((passed + failed)) total" +echo "===============================================" + +if [[ $failed -gt 0 ]]; then + echo "Cas échoués :" >&2 + for n in "${failed_names[@]}"; do + echo " - $n" >&2 + done + exit 1 +fi