premier commit
This commit is contained in:
commit
92732aa22a
34 changed files with 885 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
.vscode/
|
||||
.env
|
||||
42
00-ARCHITECTURE.md
Normal file
42
00-ARCHITECTURE.md
Normal file
|
|
@ -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é)
|
||||
40
01-SECURITY-PHILOSOPHY.md
Normal file
40
01-SECURITY-PHILOSOPHY.md
Normal file
|
|
@ -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).
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026
|
||||
|
||||
Permission is hereby granted...
|
||||
3
Makefile
Normal file
3
Makefile
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.PHONY: lint
|
||||
lint:
|
||||
nginx -t || true
|
||||
84
README.md
Normal file
84
README.md
Normal file
|
|
@ -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`).
|
||||
2
ansible/inventory/hosts.ini
Normal file
2
ansible/inventory/hosts.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[erplibre]
|
||||
erplibre1 ansible_host=CHANGE_ME ansible_user=CHANGE_ME
|
||||
12
ansible/playbooks/apply.yml
Normal file
12
ansible/playbooks/apply.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Optional CIDR whitelist, one per line (e.g., 203.0.113.10/32)
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Add ASNs here, one per line (e.g., AS9009)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Daily refresh of ASN TTL blocklist
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:15:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -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)"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[Definition]
|
||||
failregex = ^<HOST> - .* "(GET|POST) /(wp-admin|wp-login\.php|xmlrpc\.php|\.env|phpmyadmin|\.git).*" .*
|
||||
ignoreregex =
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[Definition]
|
||||
failregex = ^<HOST> - .* "(POST|GET) /web/login .*" (200|401|403) .*
|
||||
ignoreregex =
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
125
ansible/roles/erplibre_secure_reference/tasks/main.yml
Normal file
125
ansible/roles/erplibre_secure_reference/tasks/main.yml
Normal file
|
|
@ -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"
|
||||
1
asn-mitigation/asn-allowlist-v4.txt
Normal file
1
asn-mitigation/asn-allowlist-v4.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Optional CIDR whitelist, one per line (e.g., 203.0.113.10/32)
|
||||
1
asn-mitigation/asn-denylist.txt
Normal file
1
asn-mitigation/asn-denylist.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Add ASNs here, one per line (e.g., AS9009)
|
||||
8
asn-mitigation/asn-ttl-blocklist.service
Normal file
8
asn-mitigation/asn-ttl-blocklist.service
Normal file
|
|
@ -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
|
||||
9
asn-mitigation/asn-ttl-blocklist.timer
Normal file
9
asn-mitigation/asn-ttl-blocklist.timer
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Daily refresh of ASN TTL blocklist
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:15:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
43
asn-mitigation/update-asn-ttl-blocklist.sh
Normal file
43
asn-mitigation/update-asn-ttl-blocklist.sh
Normal file
|
|
@ -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)"
|
||||
42
diagram/architecture.svg
Normal file
42
diagram/architecture.svg
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="520" viewBox="0 0 1100 520">
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
|
||||
<path d="M0,0 L12,6 L0,12 z" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect x="40" y="40" width="1020" height="440" rx="18" ry="18" fill="none" stroke="black" stroke-width="2"/>
|
||||
<text x="70" y="80" font-family="Arial" font-size="22">ERPLibre Secure Reference (example: https://www.rencontres-linux.quebec)</text>
|
||||
|
||||
<rect x="90" y="120" width="240" height="80" rx="14" ry="14" fill="white" stroke="black" stroke-width="2"/>
|
||||
<text x="120" y="168" font-family="Arial" font-size="20">Internet</text>
|
||||
|
||||
<rect x="410" y="120" width="260" height="80" rx="14" ry="14" fill="white" stroke="black" stroke-width="2"/>
|
||||
<text x="430" y="153" font-family="Arial" font-size="18">nftables (DROP)</text>
|
||||
<text x="430" y="178" font-family="Arial" font-size="16">+ ASN TTL (7d)</text>
|
||||
|
||||
<rect x="760" y="120" width="260" height="80" rx="14" ry="14" fill="white" stroke="black" stroke-width="2"/>
|
||||
<text x="780" y="153" font-family="Arial" font-size="18">Nginx (host)</text>
|
||||
<text x="780" y="178" font-family="Arial" font-size="16">ports 80/443 only</text>
|
||||
|
||||
<rect x="90" y="280" width="930" height="170" rx="18" ry="18" fill="white" stroke="black" stroke-width="2"/>
|
||||
<text x="120" y="315" font-family="Arial" font-size="18">Docker network dedicated: erplibre_net (no published app/db ports)</text>
|
||||
|
||||
<rect x="160" y="340" width="360" height="80" rx="14" ry="14" fill="white" stroke="black" stroke-width="2"/>
|
||||
<text x="180" y="388" font-family="Arial" font-size="18">Odoo / ERPLibre (container)</text>
|
||||
|
||||
<rect x="590" y="340" width="360" height="80" rx="14" ry="14" fill="white" stroke="black" stroke-width="2"/>
|
||||
<text x="610" y="388" font-family="Arial" font-size="18">PostgreSQL (container)</text>
|
||||
|
||||
<!-- arrows -->
|
||||
<line x1="330" y1="160" x2="410" y2="160" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="670" y1="160" x2="760" y2="160" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="890" y1="200" x2="890" y2="280" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="520" y1="380" x2="590" y2="380" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- small labels -->
|
||||
<text x="365" y="148" font-family="Arial" font-size="14">HTTP(S)</text>
|
||||
<text x="708" y="148" font-family="Arial" font-size="14">TLS termination</text>
|
||||
<text x="905" y="250" font-family="Arial" font-size="14">reverse proxy</text>
|
||||
<text x="536" y="368" font-family="Arial" font-size="14">DB traffic</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
34
docker/docker-compose.yml
Normal file
34
docker/docker-compose.yml
Normal file
|
|
@ -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:
|
||||
61
docs/DEPLOYMENT-STEPS.md
Normal file
61
docs/DEPLOYMENT-STEPS.md
Normal file
|
|
@ -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+.
|
||||
3
fail2ban/filters/nginx-badbots.conf
Normal file
3
fail2ban/filters/nginx-badbots.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[Definition]
|
||||
failregex = ^<HOST> - .* "(GET|POST) /(wp-admin|wp-login\.php|xmlrpc\.php|\.env|phpmyadmin|\.git).*" .*
|
||||
ignoreregex =
|
||||
3
fail2ban/filters/nginx-odoo-login.conf
Normal file
3
fail2ban/filters/nginx-odoo-login.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[Definition]
|
||||
failregex = ^<HOST> - .* "(POST|GET) /web/login .*" (200|401|403) .*
|
||||
ignoreregex =
|
||||
17
fail2ban/jail.local
Normal file
17
fail2ban/jail.local
Normal file
|
|
@ -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
|
||||
13
logrotate/nginx-erplibre
Normal file
13
logrotate/nginx-erplibre
Normal file
|
|
@ -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
|
||||
}
|
||||
25
nftables/edge.nft
Normal file
25
nftables/edge.nft
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
91
nginx/erplibre.conf
Normal file
91
nginx/erplibre.conf
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue