Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours, 10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la variable, au lieu de desactiver silencieusement la protection : seules les valeurs 0, off, none, disabled et vide desactivent volontairement. Meme traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS et chaque entree de TRUSTED_PROXY_CIDRS. X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS (ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est refuse au demarrage, faute de quoi la configuration semblerait active sans l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme IPv6 ne donnent plus des compteurs distincts. MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus, pour que deux creations simultanees ne franchissent pas ensemble la derniere place. Documente comme valable pour un service applicatif unique. Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en production, une option backup --keep pour la retention, et documente Nginx, Traefik en override, cron et systemd. Corrige un bug de rendu du build local : mon edition avait reecrit docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
161
README.md
161
README.md
@@ -121,6 +121,7 @@ Toutes definies dans `.env` (modele : `.env.example`). Aucun secret n'est requis
|
||||
|---|---|---|
|
||||
| `APP_NAME` | `OpenMind` | Nom du produit, affiche dans l'interface et l'onglet |
|
||||
| `APP_PORT` | `8080` | Port publie sur l'hote (le conteneur ecoute toujours 8080) |
|
||||
| `APP_BIND` | `0.0.0.0` | Interface de publication. `127.0.0.1` en production derriere un proxy local |
|
||||
| `DATABASE_URL` | `sqlite:////app/data/openmind.db` | Base des cartes partagees |
|
||||
| `MAX_MAP_SIZE_MB` | `10` | Taille maximale d'une carte envoyee au serveur |
|
||||
| `MAX_NODES` | `5000` | Nombre maximal de noeuds par carte |
|
||||
@@ -140,10 +141,26 @@ Exposition publique :
|
||||
| `DEFAULT_SHARE_EXPIRY_DAYS` | `30` | Duree de vie par defaut d'un nouveau partage. `0` = permanent |
|
||||
| `ALLOW_PERMANENT_SHARES` | `true` | Autorise l'option "Jamais" dans le choix d'expiration |
|
||||
| `TRUST_PROXY_HEADERS` | `false` | Croire `X-Forwarded-For`. **A n'activer que derriere un reverse proxy de confiance** |
|
||||
| `TRUSTED_PROXY_CIDRS` | vide | Reseaux depuis lesquels `X-Forwarded-For` est accepte. Obligatoire si `TRUST_PROXY_HEADERS=true` |
|
||||
|
||||
Format des limites de debit : `<nombre>/<fenetre>`, ou la fenetre vaut `second`,
|
||||
`minute`, `hour` ou `day`. Une valeur vide, `0` ou `off` desactive la limite
|
||||
concernee.
|
||||
`minute`, `hour` ou `day`.
|
||||
|
||||
**La configuration est validee au demarrage.** Desactiver une limite se fait
|
||||
avec `0`, `off`, `none`, `disabled` ou une valeur vide. Toute autre valeur
|
||||
illisible arrete l'application avec un message nommant la variable :
|
||||
|
||||
```
|
||||
Configuration invalide :
|
||||
SHARE_CREATE_RATE_LIMIT : '10/hours' n'est pas une limite de debit valide :
|
||||
fenetre 'hours' inconnue. Fenetres acceptees : day, hour, minute, second.
|
||||
```
|
||||
|
||||
Une faute de frappe n'est pas une desactivation volontaire : sans ce controle,
|
||||
une instance publique tournerait sans protection en croyant en avoir une. Sont
|
||||
validees de la meme facon : `MAX_SHARED_MAPS >= 0`, `MAX_SHARE_SIZE_MB >= 0`
|
||||
(`0` = reprend `MAX_MAP_SIZE_MB`), `MAX_MAP_SIZE_MB > 0`,
|
||||
`DEFAULT_SHARE_EXPIRY_DAYS >= 0`, et chaque entree de `TRUSTED_PROXY_CIDRS`.
|
||||
|
||||
Le nom du produit est centralise : cote backend via `APP_NAME`, cote frontend
|
||||
dans `frontend/src/config/app.ts`. Le frontend recupere le nom a l'execution via
|
||||
@@ -346,6 +363,61 @@ docker compose exec openmind python -m app.cli restore /app/data/backups/openmin
|
||||
| `backup <chemin>` | Sauvegarde a chaud via l'API SQLite. `--yes` ecrase un fichier existant |
|
||||
| `restore <chemin>` | Restauration prudente : verification, copie de securite, puis remplacement |
|
||||
|
||||
`backup` accepte `--keep N` : apres ecriture, seules les `N` sauvegardes les
|
||||
plus recentes du meme dossier sont conservees, la nouvelle comprise. Sans
|
||||
`--keep`, rien n'est jamais supprime.
|
||||
|
||||
### Automatiser la purge et la sauvegarde
|
||||
|
||||
OpenMind n'embarque volontairement **aucun planificateur** : ces commandes
|
||||
s'appellent depuis l'outillage de la machine.
|
||||
|
||||
Avec cron, une entree quotidienne :
|
||||
|
||||
```cron
|
||||
# Sauvegarde a 3h15, sept jours de retention
|
||||
15 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-$(date +\%F).db --yes --keep 7
|
||||
|
||||
# Purge des partages expires a 3h30
|
||||
30 3 * * * cd /srv/openmind && docker compose exec -T openmind python -m app.cli shares purge-expired --yes
|
||||
```
|
||||
|
||||
Dans un fichier cron, `%` doit etre echappe : d'ou `\%F`.
|
||||
|
||||
Avec systemd, un service et son minuteur :
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/openmind-backup.service
|
||||
[Unit]
|
||||
Description=Sauvegarde OpenMind
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/srv/openmind
|
||||
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli backup /app/data/backups/openmind-daily.db --yes --keep 7
|
||||
ExecStart=/usr/bin/docker compose exec -T openmind python -m app.cli shares purge-expired --yes
|
||||
```
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/openmind-backup.timer
|
||||
[Unit]
|
||||
Description=Sauvegarde quotidienne d'OpenMind
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl enable --now openmind-backup.timer
|
||||
```
|
||||
|
||||
Les fichiers produits arrivent dans `./data/backups/` sur l'hote : copiez-les
|
||||
hors de la machine si vous voulez une vraie sauvegarde.
|
||||
|
||||
Aucune commande n'affiche le jeton de gestion, son hash, ni le contenu des
|
||||
cartes. Les jetons de partage y sont masques (`sbcY...BCyw`) : suffisant pour
|
||||
identifier une ligne, insuffisant pour ouvrir le lien.
|
||||
@@ -361,11 +433,38 @@ n'est imposee.
|
||||
Points a respecter :
|
||||
|
||||
1. Transmettre l'hote et le protocole d'origine.
|
||||
2. Activer `TRUST_PROXY_HEADERS=true` **uniquement** dans ce cas : la limitation
|
||||
de debit lit alors la derniere valeur de `X-Forwarded-For`, celle ajoutee par
|
||||
votre proxy. Laisse a `false`, un client direct pourrait forger cet en-tete et
|
||||
changer d'identite a chaque requete.
|
||||
3. Ne pas mettre en cache `/api/`.
|
||||
2. **Ne pas publier 8080 sur Internet.** Posez `APP_BIND=127.0.0.1` dans `.env`
|
||||
si le proxy tourne sur l'hote : OpenMind n'est alors joignable que par lui.
|
||||
Si le proxy tourne dans Docker, retirez entierement la section `ports` et
|
||||
placez les deux conteneurs sur un reseau Docker commun.
|
||||
3. Activer `TRUST_PROXY_HEADERS=true` **et** renseigner `TRUSTED_PROXY_CIDRS`
|
||||
avec les reseaux d'ou viennent les connexions du proxy. Les deux vont
|
||||
ensemble : activer la confiance sans declarer de reseau arrete le demarrage.
|
||||
4. Ne pas mettre en cache `/api/`.
|
||||
|
||||
### Comment l'adresse du client est determinee
|
||||
|
||||
`X-Forwarded-For` est un en-tete que n'importe qui peut ecrire. OpenMind ne le
|
||||
croit qu'a deux conditions cumulees :
|
||||
|
||||
| `TRUST_PROXY_HEADERS` | Connexion venant d'un reseau de `TRUSTED_PROXY_CIDRS` | Adresse retenue |
|
||||
|---|---|---|
|
||||
| `false` | peu importe | adresse reelle de la socket |
|
||||
| `true` | non | adresse reelle de la socket |
|
||||
| `true` | oui | derniere valeur de `X-Forwarded-For`, sinon `X-Real-IP`, sinon la socket |
|
||||
|
||||
OpenMind suppose **un reverse proxy frontal de confiance**. Un proxy
|
||||
correctement configure (`$proxy_add_x_forwarded_for` chez Nginx) ajoute a la fin
|
||||
de la chaine l'adresse qu'il a reellement vue : c'est donc la derniere valeur
|
||||
qui fait foi, les precedentes pouvant avoir ete forgees par le client. Aucun
|
||||
mecanisme multi-sauts n'est prevu ; avec plusieurs proxies en cascade, declarez
|
||||
uniquement celui qui est directement devant OpenMind.
|
||||
|
||||
L'adresse retenue est normalisee avec le module standard `ipaddress` : les
|
||||
differentes ecritures d'une meme IPv6 (`2001:db8::1`,
|
||||
`2001:0db8:0000:...:0001`, `2001:DB8::1`) donnent une seule cle de limitation,
|
||||
et il ne suffit donc pas de reecrire son adresse autrement pour repartir avec un
|
||||
compteur neuf.
|
||||
|
||||
Aucune variable `PUBLIC_BASE_URL` n'est necessaire : le serveur ne fabrique
|
||||
jamais d'URL absolue (il renvoie un chemin relatif `/s/<token>`), et le lien
|
||||
@@ -393,17 +492,43 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Cote OpenMind, dans `.env` :
|
||||
|
||||
```ini
|
||||
APP_BIND=127.0.0.1
|
||||
TRUST_PROXY_HEADERS=true
|
||||
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
```
|
||||
|
||||
Exemple Traefik (labels sur le service `openmind` du `docker-compose.yml`) :
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
|
||||
- traefik.http.routers.openmind.entrypoints=websecure
|
||||
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.openmind.loadbalancer.server.port=8080
|
||||
# docker-compose.override.yml — Traefik reste hors du projet.
|
||||
services:
|
||||
openmind:
|
||||
# Plus aucun port publie : seul Traefik joint le conteneur.
|
||||
ports: !reset []
|
||||
networks: [proxy]
|
||||
environment:
|
||||
TRUST_PROXY_HEADERS: "true"
|
||||
TRUSTED_PROXY_CIDRS: "172.16.0.0/12" # reseau du conteneur Traefik
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=proxy
|
||||
- traefik.http.routers.openmind.rule=Host(`mind.example.com`)
|
||||
- traefik.http.routers.openmind.entrypoints=websecure
|
||||
- traefik.http.routers.openmind.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.openmind.loadbalancer.server.port=8080
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
```
|
||||
|
||||
Traefik transmet `X-Forwarded-For`, `X-Forwarded-Proto` et `X-Real-IP` par
|
||||
defaut. Adaptez `TRUSTED_PROXY_CIDRS` au sous-reseau reel du reseau `proxy`
|
||||
(`docker network inspect proxy`).
|
||||
|
||||
Les journaux du reverse proxy contiennent les URL demandees, jetons de partage
|
||||
compris. Si cela vous derange, configurez leur format ou leur retention : c'est
|
||||
independant d'OpenMind.
|
||||
@@ -913,6 +1038,16 @@ modeles, assainissement des URL.
|
||||
auraient chacun les leurs. C'est un garde-fou contre la saturation triviale
|
||||
d'une instance publique, pas un quota strict. Un reverse proxy peut ajouter sa
|
||||
propre limitation si le besoin est plus exigeant.
|
||||
- **Capacite maximale** : le comptage et l'insertion sont serialises par un
|
||||
verrou de processus, ce qui empeche deux creations simultanees de franchir
|
||||
ensemble la derniere place. Cette garantie vaut pour l'architecture livree :
|
||||
un seul service applicatif devant un SQLite local. Avec plusieurs workers ou
|
||||
plusieurs conteneurs, `MAX_SHARED_MAPS` redeviendrait un garde-fou approximatif
|
||||
et non un quota transactionnel.
|
||||
- **Un seul niveau de proxy** : OpenMind suppose un reverse proxy frontal de
|
||||
confiance et lit la derniere valeur de `X-Forwarded-For`. Avec plusieurs
|
||||
proxies en cascade, declarez uniquement celui qui est directement devant
|
||||
l'application ; aucun mecanisme multi-sauts n'est prevu.
|
||||
- **Partage expire et information** : un lien expire repond `410` et non `404`,
|
||||
ce qui revele qu'un partage a existe a cette adresse. C'est assume : sans cela,
|
||||
la page publique ne pourrait pas expliquer la difference entre un lien expire
|
||||
|
||||
Reference in New Issue
Block a user