From b968ec8aa55624dd7ac5761a993ad339c1d4dbad Mon Sep 17 00:00:00 2001 From: Fabien Zarifian Date: Tue, 5 May 2026 13:52:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20impl=C3=A9mentation=20initiale=20du=20r?= =?UTF-8?q?everse=20proxy=20de=20maintenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 11 + .editorconfig | 15 ++ .gitignore | 9 + CHANGELOG.md | 17 ++ Dockerfile | 31 +++ PROMPT.md | 322 +++++++++++++++++++++++ README.md | 103 ++++++++ docker-compose.test.yml | 43 +++ docker-compose.yml | 22 ++ nginx/snippets/maintenance-log.conf | 3 + nginx/templates/default.conf.template | 49 ++++ public/maintenance.html | 78 ++++++ scripts/build-ip-list.sh | 60 +++++ scripts/entrypoint.sh | 66 +++++ scripts/lint.sh | 47 ++++ tests/cases/blacklist_blocked_ip.sh | 15 ++ tests/cases/blacklist_normal_ip.sh | 15 ++ tests/cases/log_dedicated.sh | 37 +++ tests/cases/nginx_syntax.sh | 20 ++ tests/cases/whitelist_authorized_ip.sh | 15 ++ tests/cases/whitelist_unauthorized_ip.sh | 15 ++ tests/fixtures/upstream/index.html | 5 + tests/lib.sh | 70 +++++ tests/run.sh | 79 ++++++ 24 files changed, 1147 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 PROMPT.md create mode 100644 README.md create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 nginx/snippets/maintenance-log.conf create mode 100644 nginx/templates/default.conf.template create mode 100644 public/maintenance.html create mode 100755 scripts/build-ip-list.sh create mode 100755 scripts/entrypoint.sh create mode 100755 scripts/lint.sh create mode 100755 tests/cases/blacklist_blocked_ip.sh create mode 100755 tests/cases/blacklist_normal_ip.sh create mode 100755 tests/cases/log_dedicated.sh create mode 100755 tests/cases/nginx_syntax.sh create mode 100755 tests/cases/whitelist_authorized_ip.sh create mode 100755 tests/cases/whitelist_unauthorized_ip.sh create mode 100644 tests/fixtures/upstream/index.html create mode 100644 tests/lib.sh create mode 100755 tests/run.sh 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