commit 92732aa22a199c3d89a234ac2dc9a20b794fac5b Author: Daniel Allaire Date: Tue Mar 3 09:42:18 2026 -0500 premier commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae69fd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.swp +*.swo +*.log +.DS_Store +__pycache__/ +.vscode/ +.env diff --git a/00-ARCHITECTURE.md b/00-ARCHITECTURE.md new file mode 100644 index 0000000..2b43986 --- /dev/null +++ b/00-ARCHITECTURE.md @@ -0,0 +1,42 @@ +# Modèle de référence – Architecture + +Ce modèle s’applique aux instances ERPLibre (Odoo) **équivalentes** : + +- Odoo conteneurisé +- PostgreSQL conteneurisé +- Nginx sur l’hôte +- Exposition publique via 80/443 +- Docker standard +- Debian/Ubuntu moderne + +Exemple de domaine utilisé dans la documentation : **www.rencontres-linux.quebec** (illustratif) + +## Topologie + +``` +Internet + ↓ +nftables (policy DROP + ASN TTL léger) + ↓ +Nginx (hôte) + ↓ +Docker network dédié (erplibre_net) + ↓ +Odoo + ↓ +PostgreSQL +``` + +## Règles d’or + +1. **Ne jamais publier** 8069/8072 (Odoo) vers 0.0.0.0. +2. **Ne jamais publier** 5432 (PostgreSQL) vers 0.0.0.0. +3. **Nginx est l’unique** point d’entrée public. +4. Pare-feu : **DROP par défaut**, exceptions explicites (80/443). +5. Mitigation : **comportementale**, pas de géoblocage massif. + +## Flux + +- Client → Nginx (443) +- Nginx → Odoo (réseau Docker dédié) +- Odoo → PostgreSQL (réseau Docker dédié) diff --git a/01-SECURITY-PHILOSOPHY.md b/01-SECURITY-PHILOSOPHY.md new file mode 100644 index 0000000..5af5fba --- /dev/null +++ b/01-SECURITY-PHILOSOPHY.md @@ -0,0 +1,40 @@ +# Sécurité numérique responsable (mode A) + +## Positionnement + +L’objectif est de protéger un service public (ou communautaire) sans sur-complexifier : + +- Réduction de la surface d’attaque +- Défense multicouche proportionnée +- Mesures réversibles +- Confort opérationnel + +## Pourquoi pas de géoblocage ? + +Un site multilingue a une audience potentiellement mondiale. Les blocages géographiques : +- créent des faux positifs massifs, +- n’apportent pas une mitigation fiable, +- sont difficiles à justifier publiquement. + +## Pourquoi ASN TTL ? + +L’ASN TTL : +- cible des sources de bruit « structurel », +- reste réversible (TTL), +- permet une escalade mesurée. + +Recommandation mode A : +- Fail2ban : 24h +- ASN : seuil ≥ 40 IP bannies / 24h +- TTL : 7 jours + +## Pourquoi WAF léger ? + +Un WAF strict génère des faux positifs coûteux à gérer. Un WAF léger (CRS niveau 1) : +- bloque l’évidence, +- demande peu de tuning, +- protège sans pénaliser les usagers. + +## Pourquoi nftables ? + +nftables est moderne, lisible, et permet des structures (sets) efficaces (ex. ASN TTL). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..886615d --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted... diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..db13efe --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +.PHONY: lint +lint: + nginx -t || true diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c82425 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# ERPLibre – Secure Reference Deployment + +## Sécurité numérique responsable + +Ce dépôt constitue un **modèle de référence** applicable à toute instance ERPLibre (Odoo) **conteneurisée**, exposée publiquement via **Nginx sur l’hôte**, avec **PostgreSQL conteneurisé**. + +Exemple d’implémentation (illustratif) : **https://www.rencontres-linux.quebec** + +--- + +## Architecture cible + +``` +Internet + ↓ +nftables (policy DROP + mitigation ASN TTL légère) + ↓ +Nginx (hôte) + ↓ +Réseau Docker dédié (erplibre_net) + ↓ +Odoo (conteneur, pas de port publié) + ↓ +PostgreSQL (conteneur, pas de port publié) +``` + +--- + +## Principes + +- **Surface minimale** : seuls ports **80/443** exposés. +- **Segmentation Docker** : réseau dédié par stack. +- **Pare-feu moderne** : nftables (DROP par défaut), règles explicites. +- **Mitigation proportionnée** : Fail2ban (24h) + ASN TTL (7 jours). +- **WAF léger** : ModSecurity + OWASP CRS (niveau 1), approche prudente. + +--- + +## Démarrage rapide + +1. Créer le réseau Docker dédié : + ```bash + docker network create erplibre_net + ``` + +2. Déployer la stack (exemple) : + ```bash + cd docker + docker compose up -d + ``` + +3. Installer / activer Nginx + config du vhost : + - `nginx/erplibre.conf` (exemple) + - Certificat TLS via Certbot (hors du périmètre de ce dépôt) + +4. Activer le pare-feu nftables : + - `nftables/edge.nft` + +5. Installer Fail2ban : + - `fail2ban/jail.local` (+ filtres au besoin) + +6. Activer la mitigation ASN TTL : + - `asn-mitigation/*` (script + systemd timer) + +Voir : `docs/DEPLOYMENT-STEPS.md` + +--- + +## Contenu du dépôt + +- `docker/` : stack Docker (Odoo + PostgreSQL) sur réseau dédié +- `nginx/` : vhost Nginx durci (exemple) +- `nftables/` : pare-feu minimal (DROP + ASN TTL) +- `fail2ban/` : jails de base +- `asn-mitigation/` : script + unit/timer systemd +- `logrotate/` : rotation des logs Nginx +- `ansible/` : playbook minimal (mode A) pour appliquer ces artefacts +- `diagram/` : schéma (SVG) + +--- + +## Licence + +MIT (voir `LICENSE`). diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000..b71b621 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,2 @@ +[erplibre] +erplibre1 ansible_host=CHANGE_ME ansible_user=CHANGE_ME diff --git a/ansible/playbooks/apply.yml b/ansible/playbooks/apply.yml new file mode 100644 index 0000000..0ebc674 --- /dev/null +++ b/ansible/playbooks/apply.yml @@ -0,0 +1,12 @@ +--- +- name: Apply ERPLibre secure reference (mode A) + hosts: erplibre + become: true + + vars: + domain_example: "www.rencontres-linux.quebec" + nginx_site_src: "../roles/erplibre_secure_reference/files/nginx/erplibre.conf" + nginx_site_dest: "/etc/nginx/sites-available/erplibre.conf" + + roles: + - erplibre_secure_reference diff --git a/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-allowlist-v4.txt b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-allowlist-v4.txt new file mode 100644 index 0000000..e174273 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-allowlist-v4.txt @@ -0,0 +1 @@ +# Optional CIDR whitelist, one per line (e.g., 203.0.113.10/32) diff --git a/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-denylist.txt b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-denylist.txt new file mode 100644 index 0000000..7e3b5e4 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-denylist.txt @@ -0,0 +1 @@ +# Add ASNs here, one per line (e.g., AS9009) diff --git a/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-ttl-blocklist.service b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-ttl-blocklist.service new file mode 100644 index 0000000..0fc64b9 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-ttl-blocklist.service @@ -0,0 +1,8 @@ +[Unit] +Description=Refresh ASN TTL blocklist into nftables +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/update-asn-ttl-blocklist.sh diff --git a/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-ttl-blocklist.timer b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-ttl-blocklist.timer new file mode 100644 index 0000000..601e1b7 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/asn-ttl-blocklist.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Daily refresh of ASN TTL blocklist + +[Timer] +OnCalendar=*-*-* 03:15:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ansible/roles/erplibre_secure_reference/files/asn-mitigation/update-asn-ttl-blocklist.sh b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/update-asn-ttl-blocklist.sh new file mode 100644 index 0000000..59dd58a --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/asn-mitigation/update-asn-ttl-blocklist.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +DENYLIST="/etc/edge/asn-denylist.txt" +ALLOWLIST="/etc/edge/asn-allowlist-v4.txt" +TMP="/tmp/asn_prefixes_v4.txt" +SETDENY="inet filter asn_deny_v4" +SETALLOW="inet filter asn_allow_v4" +TTL="7d" + +command -v nft >/dev/null +command -v curl >/dev/null +command -v jq >/dev/null + +mkdir -p /etc/edge +: > "$TMP" + +# Allowlist (static) +if [[ -f "$ALLOWLIST" ]]; then + nft "flush set $SETALLOW" || true + while read -r cidr; do + [[ -z "${cidr}" || "${cidr:0:1}" == "#" ]] && continue + nft "add element $SETALLOW { $cidr }" || true + done < "$ALLOWLIST" +fi + +# RIPEstat: announced prefixes for an ASN +while read -r asn; do + [[ -z "${asn}" || "${asn:0:1}" == "#" ]] && continue + asn="${asn#AS}" + + url="https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}" + curl -fsS "$url" | jq -r '.data.prefixes[]?.prefix' | awk -F/ '$1 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {print $0}' >> "$TMP" +done < "$DENYLIST" + +sort -u -o "$TMP" "$TMP" + +# Refresh TTL by re-adding elements +while read -r cidr; do + nft "add element $SETDENY { $cidr timeout $TTL }" || true +done < "$TMP" + +echo "ASN deny refreshed: $(wc -l < "$TMP") IPv4 prefixes (TTL=$TTL)" diff --git a/ansible/roles/erplibre_secure_reference/files/fail2ban/filters/nginx-badbots.conf b/ansible/roles/erplibre_secure_reference/files/fail2ban/filters/nginx-badbots.conf new file mode 100644 index 0000000..f73fda8 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/fail2ban/filters/nginx-badbots.conf @@ -0,0 +1,3 @@ +[Definition] +failregex = ^ - .* "(GET|POST) /(wp-admin|wp-login\.php|xmlrpc\.php|\.env|phpmyadmin|\.git).*" .* +ignoreregex = diff --git a/ansible/roles/erplibre_secure_reference/files/fail2ban/filters/nginx-odoo-login.conf b/ansible/roles/erplibre_secure_reference/files/fail2ban/filters/nginx-odoo-login.conf new file mode 100644 index 0000000..f3170f3 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/fail2ban/filters/nginx-odoo-login.conf @@ -0,0 +1,3 @@ +[Definition] +failregex = ^ - .* "(POST|GET) /web/login .*" (200|401|403) .* +ignoreregex = diff --git a/ansible/roles/erplibre_secure_reference/files/fail2ban/jail.local b/ansible/roles/erplibre_secure_reference/files/fail2ban/jail.local new file mode 100644 index 0000000..050bbc4 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/fail2ban/jail.local @@ -0,0 +1,17 @@ +[nginx-odoo-login] +enabled = true +port = http,https +filter = nginx-odoo-login +logpath = /var/log/nginx/rencontres-linux-access.log +maxretry = 8 +findtime = 10m +bantime = 24h + +[nginx-badbots] +enabled = true +port = http,https +filter = nginx-badbots +logpath = /var/log/nginx/rencontres-linux-access.log +maxretry = 20 +findtime = 10m +bantime = 24h diff --git a/ansible/roles/erplibre_secure_reference/files/logrotate/nginx-erplibre b/ansible/roles/erplibre_secure_reference/files/logrotate/nginx-erplibre new file mode 100644 index 0000000..782c2c5 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/logrotate/nginx-erplibre @@ -0,0 +1,13 @@ +/var/log/nginx/rencontres-linux-*.log { + daily + missingok + rotate 30 + compress + delaycompress + notifempty + create 0640 www-data adm + sharedscripts + postrotate + systemctl reload nginx > /dev/null 2>&1 || true + endscript +} diff --git a/ansible/roles/erplibre_secure_reference/files/nftables/edge.nft b/ansible/roles/erplibre_secure_reference/files/nftables/edge.nft new file mode 100644 index 0000000..8b32005 --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/nftables/edge.nft @@ -0,0 +1,25 @@ +table inet filter { + + set asn_deny_v4 { + type ipv4_addr + flags interval, timeout + timeout 7d + auto-merge + } + + chain input { + type filter hook input priority 0; + policy drop; + + ct state established,related accept + iif lo accept + + # Public: only HTTP/HTTPS + tcp dport {80,443} accept + + # ASN TTL drop (optional) + ip saddr @asn_deny_v4 drop + + reject with icmpx type port-unreachable + } +} diff --git a/ansible/roles/erplibre_secure_reference/files/nginx/erplibre.conf b/ansible/roles/erplibre_secure_reference/files/nginx/erplibre.conf new file mode 100644 index 0000000..119e61d --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/files/nginx/erplibre.conf @@ -0,0 +1,91 @@ +# Exemple de vhost Nginx (Nginx sur l'hôte) – domaine illustratif: www.rencontres-linux.quebec +# À adapter: chemins TLS (Certbot), upstream (IP/nom du conteneur), logs. + +# Quick win: réduire fingerprinting +server_tokens off; + +# Zones (rate limit) – à placer dans http{} si vous centralisez +limit_req_zone $binary_remote_addr zone=LOGIN:10m rate=5r/m; + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Upstreams (exemple) +# Recommandé: pointer vers le conteneur via le réseau docker dédié (DNS docker) si Nginx est aussi dans docker. +# Ici Nginx est sur l'hôte: on pointera vers l'IP du conteneur Odoo sur erplibre_net (ou via un reverse proxy local). +upstream odoo_backend { + server 127.0.0.1:18069; # TODO: remplacer par un mécanisme stable (ex: socat, ou IP fixe, ou Nginx dans docker) + keepalive 32; +} + +upstream odoo_bus { + server 127.0.0.1:18072; # TODO idem + keepalive 16; +} + +server { + listen 80; + server_name www.rencontres-linux.quebec rencontres-linux.quebec; + + location /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + try_files $uri =404; + } + + location / { + return 301 https://www.rencontres-linux.quebec$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name www.rencontres-linux.quebec; + + # TODO Certbot + ssl_certificate /etc/letsencrypt/live/www.rencontres-linux.quebec/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.rencontres-linux.quebec/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + access_log /var/log/nginx/rencontres-linux-access.log; + error_log /var/log/nginx/rencontres-linux-error.log warn; + + client_max_body_size 512M; + + # Web login rate limit + location = /web/login { + limit_req zone=LOGIN burst=10 nodelay; + proxy_pass http://odoo_backend; + } + + # Websocket/bus + location /websocket { + proxy_pass http://odoo_bus; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://odoo_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_redirect off; + } +} diff --git a/ansible/roles/erplibre_secure_reference/tasks/main.yml b/ansible/roles/erplibre_secure_reference/tasks/main.yml new file mode 100644 index 0000000..fd2b3bc --- /dev/null +++ b/ansible/roles/erplibre_secure_reference/tasks/main.yml @@ -0,0 +1,125 @@ +--- +- name: Install baseline packages + apt: + name: + - nftables + - nginx + - fail2ban + - jq + - curl + state: present + update_cache: true + +- name: Ensure directories + file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - /etc/nftables.d + - /etc/edge + - /var/www/letsencrypt + +- name: Deploy nftables edge policy + copy: + src: "nftables/edge.nft" + dest: "/etc/nftables.d/edge.nft" + mode: "0644" + +- name: Ensure nftables.conf includes /etc/nftables.d/*.nft + lineinfile: + path: /etc/nftables.conf + line: 'include "/etc/nftables.d/*.nft"' + create: true + +- name: Enable and apply nftables + systemd: + name: nftables + enabled: true + state: restarted + +- name: Deploy nginx site + copy: + src: "nginx/erplibre.conf" + dest: "/etc/nginx/sites-available/erplibre.conf" + mode: "0644" + +- name: Enable nginx site + file: + src: /etc/nginx/sites-available/erplibre.conf + dest: /etc/nginx/sites-enabled/erplibre.conf + state: link + force: true + +- name: Remove default nginx site if present + file: + path: /etc/nginx/sites-enabled/default + state: absent + +- name: Test nginx configuration + command: nginx -t + changed_when: false + +- name: Reload nginx + systemd: + name: nginx + state: reloaded + enabled: true + +- name: Deploy fail2ban jail + copy: + src: "fail2ban/jail.local" + dest: "/etc/fail2ban/jail.d/erplibre.local" + mode: "0644" + +- name: Deploy fail2ban filters + copy: + src: "fail2ban/filters/" + dest: "/etc/fail2ban/filter.d/" + mode: "0644" + +- name: Restart fail2ban + systemd: + name: fail2ban + state: restarted + enabled: true + +- name: Deploy ASN mitigation script and lists + copy: + src: "asn-mitigation/update-asn-ttl-blocklist.sh" + dest: "/usr/local/sbin/update-asn-ttl-blocklist.sh" + mode: "0750" + +- name: Deploy ASN deny/allow lists (templates) + copy: + src: "asn-mitigation/{{ item }}" + dest: "/etc/edge/{{ item }}" + mode: "0644" + loop: + - asn-denylist.txt + - asn-allowlist-v4.txt + +- name: Deploy systemd units for ASN timer + copy: + src: "asn-mitigation/{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + mode: "0644" + loop: + - asn-ttl-blocklist.service + - asn-ttl-blocklist.timer + +- name: systemd daemon-reload + systemd: + daemon_reload: true + +- name: Enable ASN timer + systemd: + name: asn-ttl-blocklist.timer + enabled: true + state: started + +- name: Deploy logrotate rule + copy: + src: "logrotate/nginx-erplibre" + dest: "/etc/logrotate.d/nginx-erplibre" + mode: "0644" diff --git a/asn-mitigation/asn-allowlist-v4.txt b/asn-mitigation/asn-allowlist-v4.txt new file mode 100644 index 0000000..e174273 --- /dev/null +++ b/asn-mitigation/asn-allowlist-v4.txt @@ -0,0 +1 @@ +# Optional CIDR whitelist, one per line (e.g., 203.0.113.10/32) diff --git a/asn-mitigation/asn-denylist.txt b/asn-mitigation/asn-denylist.txt new file mode 100644 index 0000000..7e3b5e4 --- /dev/null +++ b/asn-mitigation/asn-denylist.txt @@ -0,0 +1 @@ +# Add ASNs here, one per line (e.g., AS9009) diff --git a/asn-mitigation/asn-ttl-blocklist.service b/asn-mitigation/asn-ttl-blocklist.service new file mode 100644 index 0000000..0fc64b9 --- /dev/null +++ b/asn-mitigation/asn-ttl-blocklist.service @@ -0,0 +1,8 @@ +[Unit] +Description=Refresh ASN TTL blocklist into nftables +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/update-asn-ttl-blocklist.sh diff --git a/asn-mitigation/asn-ttl-blocklist.timer b/asn-mitigation/asn-ttl-blocklist.timer new file mode 100644 index 0000000..601e1b7 --- /dev/null +++ b/asn-mitigation/asn-ttl-blocklist.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Daily refresh of ASN TTL blocklist + +[Timer] +OnCalendar=*-*-* 03:15:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/asn-mitigation/update-asn-ttl-blocklist.sh b/asn-mitigation/update-asn-ttl-blocklist.sh new file mode 100644 index 0000000..59dd58a --- /dev/null +++ b/asn-mitigation/update-asn-ttl-blocklist.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +DENYLIST="/etc/edge/asn-denylist.txt" +ALLOWLIST="/etc/edge/asn-allowlist-v4.txt" +TMP="/tmp/asn_prefixes_v4.txt" +SETDENY="inet filter asn_deny_v4" +SETALLOW="inet filter asn_allow_v4" +TTL="7d" + +command -v nft >/dev/null +command -v curl >/dev/null +command -v jq >/dev/null + +mkdir -p /etc/edge +: > "$TMP" + +# Allowlist (static) +if [[ -f "$ALLOWLIST" ]]; then + nft "flush set $SETALLOW" || true + while read -r cidr; do + [[ -z "${cidr}" || "${cidr:0:1}" == "#" ]] && continue + nft "add element $SETALLOW { $cidr }" || true + done < "$ALLOWLIST" +fi + +# RIPEstat: announced prefixes for an ASN +while read -r asn; do + [[ -z "${asn}" || "${asn:0:1}" == "#" ]] && continue + asn="${asn#AS}" + + url="https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}" + curl -fsS "$url" | jq -r '.data.prefixes[]?.prefix' | awk -F/ '$1 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {print $0}' >> "$TMP" +done < "$DENYLIST" + +sort -u -o "$TMP" "$TMP" + +# Refresh TTL by re-adding elements +while read -r cidr; do + nft "add element $SETDENY { $cidr timeout $TTL }" || true +done < "$TMP" + +echo "ASN deny refreshed: $(wc -l < "$TMP") IPv4 prefixes (TTL=$TTL)" diff --git a/diagram/architecture.svg b/diagram/architecture.svg new file mode 100644 index 0000000..8ee1f0b --- /dev/null +++ b/diagram/architecture.svg @@ -0,0 +1,42 @@ + + + + + + + + + ERPLibre Secure Reference (example: https://www.rencontres-linux.quebec) + + + Internet + + + nftables (DROP) + + ASN TTL (7d) + + + Nginx (host) + ports 80/443 only + + + Docker network dedicated: erplibre_net (no published app/db ports) + + + Odoo / ERPLibre (container) + + + PostgreSQL (container) + + + + + + + + + HTTP(S) + TLS termination + reverse proxy + DB traffic + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..e74a9fe --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.9" + +networks: + erplibre_net: + driver: bridge + +services: + postgres: + image: postgres:15 + networks: + - erplibre_net + volumes: + - pgdata:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: change_me + POSTGRES_USER: odoo + POSTGRES_DB: odoo + + odoo: + image: odoo:18 + depends_on: + - postgres + networks: + - erplibre_net + expose: + - "8069" + - "8072" + environment: + HOST: postgres + USER: odoo + PASSWORD: change_me + +volumes: + pgdata: diff --git a/docs/DEPLOYMENT-STEPS.md b/docs/DEPLOYMENT-STEPS.md new file mode 100644 index 0000000..e5f727a --- /dev/null +++ b/docs/DEPLOYMENT-STEPS.md @@ -0,0 +1,61 @@ +# Déploiement – modèle de référence + +Exemple illustratif : https://www.rencontres-linux.quebec + +## Pré-requis + +- Debian/Ubuntu moderne +- Docker Engine + Docker Compose +- Nginx sur l’hôte +- nftables +- (optionnel) fail2ban, modsecurity + +## Étapes + + 1. Créer un réseau Docker dédié : + + ```bash + docker network create erplibre_net + ``` + 2. Adapter `docker/docker-compose.yml` (mots de passe, image ERPLibre). + 3. Démarrer la stack : + + ```bash + cd docker + docker compose up -d + ``` + 4. Vérifier qu’aucun port applicatif n’est publié : + + ```bash + docker ps --format 'table {.Names}\t{.Ports}' + ``` + 5. Installer Nginx et activer le vhost : + - Copier `nginx/erplibre.conf` vers `/etc/nginx/sites-available/` + - Adapter `server_name` et les chemins TLS Certbot + - `nginx -t && systemctl reload nginx` + 6. TLS (Certbot) : + - Préparer `/var/www/letsencrypt` + - Générer/renouveler les certificats + 7. Activer nftables : + - Copier `nftables/edge.nft` dans `/etc/nftables.d/` + - Inclure `/etc/nftables.d/*.nft` dans `/etc/nftables.conf` + - `nft -f /etc/nftables.conf` + - `systemctl enable --now nftables` + 8. Fail2ban : + - Copier `fail2ban/jail.local` dans `/etc/fail2ban/jail.d/erplibre.local` + - Copier les filtres dans `/etc/fail2ban/filter.d/` + - `systemctl restart fail2ban` + 9. Mitigation ASN TTL : + - Copier `asn-mitigation/update-asn-ttl-blocklist.sh` vers `/usr/local/sbin/` + - Copier les listes vers `/etc/edge/` + - Copier les units systemd vers `/etc/systemd/system/` + - `systemctl daemon-reload` + - `systemctl enable --now asn-ttl-blocklist.timer` +10. Logrotate : + +- Copier `logrotate/nginx-erplibre` vers `/etc/logrotate.d/` + +## Tests + +- Depuis l’extérieur : seuls 80/443 doivent répondre. +- SSL Labs : viser A/A+. \ No newline at end of file diff --git a/fail2ban/filters/nginx-badbots.conf b/fail2ban/filters/nginx-badbots.conf new file mode 100644 index 0000000..f73fda8 --- /dev/null +++ b/fail2ban/filters/nginx-badbots.conf @@ -0,0 +1,3 @@ +[Definition] +failregex = ^ - .* "(GET|POST) /(wp-admin|wp-login\.php|xmlrpc\.php|\.env|phpmyadmin|\.git).*" .* +ignoreregex = diff --git a/fail2ban/filters/nginx-odoo-login.conf b/fail2ban/filters/nginx-odoo-login.conf new file mode 100644 index 0000000..f3170f3 --- /dev/null +++ b/fail2ban/filters/nginx-odoo-login.conf @@ -0,0 +1,3 @@ +[Definition] +failregex = ^ - .* "(POST|GET) /web/login .*" (200|401|403) .* +ignoreregex = diff --git a/fail2ban/jail.local b/fail2ban/jail.local new file mode 100644 index 0000000..050bbc4 --- /dev/null +++ b/fail2ban/jail.local @@ -0,0 +1,17 @@ +[nginx-odoo-login] +enabled = true +port = http,https +filter = nginx-odoo-login +logpath = /var/log/nginx/rencontres-linux-access.log +maxretry = 8 +findtime = 10m +bantime = 24h + +[nginx-badbots] +enabled = true +port = http,https +filter = nginx-badbots +logpath = /var/log/nginx/rencontres-linux-access.log +maxretry = 20 +findtime = 10m +bantime = 24h diff --git a/logrotate/nginx-erplibre b/logrotate/nginx-erplibre new file mode 100644 index 0000000..782c2c5 --- /dev/null +++ b/logrotate/nginx-erplibre @@ -0,0 +1,13 @@ +/var/log/nginx/rencontres-linux-*.log { + daily + missingok + rotate 30 + compress + delaycompress + notifempty + create 0640 www-data adm + sharedscripts + postrotate + systemctl reload nginx > /dev/null 2>&1 || true + endscript +} diff --git a/nftables/edge.nft b/nftables/edge.nft new file mode 100644 index 0000000..8b32005 --- /dev/null +++ b/nftables/edge.nft @@ -0,0 +1,25 @@ +table inet filter { + + set asn_deny_v4 { + type ipv4_addr + flags interval, timeout + timeout 7d + auto-merge + } + + chain input { + type filter hook input priority 0; + policy drop; + + ct state established,related accept + iif lo accept + + # Public: only HTTP/HTTPS + tcp dport {80,443} accept + + # ASN TTL drop (optional) + ip saddr @asn_deny_v4 drop + + reject with icmpx type port-unreachable + } +} diff --git a/nginx/erplibre.conf b/nginx/erplibre.conf new file mode 100644 index 0000000..119e61d --- /dev/null +++ b/nginx/erplibre.conf @@ -0,0 +1,91 @@ +# Exemple de vhost Nginx (Nginx sur l'hôte) – domaine illustratif: www.rencontres-linux.quebec +# À adapter: chemins TLS (Certbot), upstream (IP/nom du conteneur), logs. + +# Quick win: réduire fingerprinting +server_tokens off; + +# Zones (rate limit) – à placer dans http{} si vous centralisez +limit_req_zone $binary_remote_addr zone=LOGIN:10m rate=5r/m; + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Upstreams (exemple) +# Recommandé: pointer vers le conteneur via le réseau docker dédié (DNS docker) si Nginx est aussi dans docker. +# Ici Nginx est sur l'hôte: on pointera vers l'IP du conteneur Odoo sur erplibre_net (ou via un reverse proxy local). +upstream odoo_backend { + server 127.0.0.1:18069; # TODO: remplacer par un mécanisme stable (ex: socat, ou IP fixe, ou Nginx dans docker) + keepalive 32; +} + +upstream odoo_bus { + server 127.0.0.1:18072; # TODO idem + keepalive 16; +} + +server { + listen 80; + server_name www.rencontres-linux.quebec rencontres-linux.quebec; + + location /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + try_files $uri =404; + } + + location / { + return 301 https://www.rencontres-linux.quebec$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name www.rencontres-linux.quebec; + + # TODO Certbot + ssl_certificate /etc/letsencrypt/live/www.rencontres-linux.quebec/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/www.rencontres-linux.quebec/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + access_log /var/log/nginx/rencontres-linux-access.log; + error_log /var/log/nginx/rencontres-linux-error.log warn; + + client_max_body_size 512M; + + # Web login rate limit + location = /web/login { + limit_req zone=LOGIN burst=10 nodelay; + proxy_pass http://odoo_backend; + } + + # Websocket/bus + location /websocket { + proxy_pass http://odoo_bus; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://odoo_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_redirect off; + } +}