diff --git a/.gitignore b/.gitignore index 8a84890..8dafe34 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ htmlcov/ .env.local webapp.env +# Certificat TLS auto-signé (webapp HTTPS / GPS téléphone, cf. make-tls-cert.sh) +tls/ + # Données et artefacts internes (jamais dans le dépôt) data/ .swival/ diff --git a/AGENTS.md b/AGENTS.md index bd591f9..f0586d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - build: `docker build -t lidar-lidar .` - build webapp légère (Raspberry Pi, déploiement 2 machines — cf. `docs/DEPLOY_WEBAPP.md`): `docker compose -f docker-compose.webapp.yml up -d --build` (image `Dockerfile.webapp`, sans PDAL/GPU) - build générateur de tuiles (machine de traitement): `docker compose -f docker-compose.worker.yml up -d --build` (service `worker`, API pour les webapp distantes) -- simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` (rsync local depuis `./output` monté `/data/remote:ro`, auto-sync 120 s). Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS). +- simulation locale du mode deux machines : `docker compose -f docker-compose.local-2m.yml up -d --build` — worker GPU sur :8974 + webapp légère sur :8973 avec son PROPRE cache `output-webapp/` peuplé à la demande depuis le worker. Permet de rebuild l'interface sans toucher au worker, et réciproquement. Résolution 0,2 m uniquement (GENERATE_RESOLUTIONS). - stack webapp (machine légère): `./serve-webapp.sh [start|stop|restart|status|sync|logs|update]`, config dans `webapp.env` (modèle `webapp.env.example`, ignoré par git) - test all: `./run.sh --test` (rebuild automatique de l'image avant les tests ; en `docker run` direct, rebuild manuellement d'abord) - test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.` diff --git a/Dockerfile.webapp b/Dockerfile.webapp index 46f37e6..5cd2422 100644 --- a/Dockerfile.webapp +++ b/Dockerfile.webapp @@ -11,10 +11,10 @@ FROM python:3.12-slim ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Europe/Paris -# rsync + client SSH : synchronisation des tuiles depuis la machine de -# traitement (LIDAR_SYNC_CMD). La clé se monte dans /home/lidar/.ssh. +# Client SSH : inspection/accès à la machine de traitement si besoin. La +# clé se monte dans /home/lidar/.ssh. Les tuiles étant servies à la demande +# (plus de rsync dans l'image), seul le client SSH est conservé. RUN apt-get update && apt-get install -y --no-install-recommends \ - rsync \ openssh-client \ && rm -rf /var/lib/apt/lists/* @@ -32,11 +32,13 @@ RUN pip3 install --no-cache-dir \ # Code : webapp.py + index.py seulement utilisés — les modules de traitement # (dtm, visualizations...) sont copiés mais jamais importés par la webapp. COPY lidar_pipeline/ ./lidar_pipeline/ +COPY webapp-entrypoint.sh /webapp-entrypoint.sh RUN groupadd -g 1000 lidar && \ useradd -u 1000 -g lidar -m -s /bin/bash lidar && \ mkdir -p /data/output /data/input && \ - chown -R lidar:lidar /data + chown -R lidar:lidar /data && \ + chmod +x /webapp-entrypoint.sh ENV LIDAR_INPUT_DIR=/data/input \ LIDAR_OUTPUT_DIR=/data/output \ @@ -46,4 +48,6 @@ USER lidar EXPOSE 8973 -CMD ["python3", "-m", "uvicorn", "lidar_pipeline.webapp:app", "--host", "0.0.0.0", "--port", "8973"] +# Sert en HTTP, ou en HTTPS si LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE sont +# définis (requis pour la géolocalisation GPS depuis un téléphone). +CMD ["/webapp-entrypoint.sh"] diff --git a/docker-compose.local-2m.yml b/docker-compose.local-2m.yml index 4ce425a..d8f44a9 100644 --- a/docker-compose.local-2m.yml +++ b/docker-compose.local-2m.yml @@ -6,9 +6,8 @@ # génération sur le port hôte 8974 ; # - webapp : interface légère (image Dockerfile.webapp, sans PDAL/GPU) — # carte sur le port hôte 8973, avec son PROPRE cache de tuiles -# (./output-webapp) alimenté par rsync depuis ./output du -# worker (monté en lecture seule dans la webapp), comme le -# Raspberry Pi le fait par SSH vers la machine de traitement. +# (./output-webapp) peuplé à la demande depuis le worker, +# comme le fait le Raspberry Pi. # # Intérêt en local : mettre à jour le code de l'interface (rebuild webapp) # sans toucher au worker ni aux générations en cours, et réciproquement. @@ -52,21 +51,14 @@ services: - "8973:8973" volumes: # Cache local propre à la webapp (équivalent du disque du Raspberry - # Pi) — PAS le ./output du worker : la séparation rendu/interface - # doit être réelle, les tuiles arrivent par le rsync ci-dessous. + # Pi) — PAS le ./output du worker : la séparation rendu/interface doit + # être réelle, les tuiles arrivent à la demande depuis le worker. - ./output-webapp:/data/output - # Sortie du worker, en lecture seule : source du rsync local qui - # remplace le "rsync par SSH vers la machine de traitement". - - ./output:/data/remote:ro environment: - TZ=Europe/Paris - LIDAR_OUTPUT_DIR=/data/output - # Backend de génération = worker du même réseau compose (DNS docker) + # Backend de génération = worker du même réseau compose (DNS docker) ; + # les tuiles sont servies à la demande depuis le worker (cache peuplé + # au fil des consultations, vignettes régénérées localement par /api/sync) - LIDAR_GENERATION_URL=http://lidar-worker:8973 - # Rapatriement des tuiles : mêmes exclusions que la prod (les - # vignettes/sous-tuiles sont régénérées localement par /api/sync) - - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles /data/remote/ /data/output/ - # Cache auto-entretenu : resynchronisation + vignettes toutes les - # 2 min (plus réactif que la prod pour tester en local) - - LIDAR_AUTO_SYNC_SECONDS=120 restart: unless-stopped diff --git a/docker-compose.webapp.override.yml.example b/docker-compose.webapp.override.yml.example new file mode 100644 index 0000000..5583037 --- /dev/null +++ b/docker-compose.webapp.override.yml.example @@ -0,0 +1,37 @@ +# docker-compose.webapp.override.yml — EXEMPLE (copier sans le suffixe .example) +# +# NON VERSIONNÉ (ignoré par git) : il vit sur le Pi. Ce fichier démontre la +# config Traefik qui termine le TLS (certificat porté par le proxy) et expose +# la carte en https:// — contexte sécurisé requis par le bouton ⌖ +# (« centrer sur la position GPS » du téléphone). +# +# cp docker-compose.webapp.override.yml.example docker-compose.webapp.override.yml +# # adapter LIDAR_WEBAPP_HOST (et si besoin l'entrypoint / le certresolver) +# +# docker compose ne merge PAS automatiquement un override au nom custom +# (il n'auto-charge que docker-compose.override.yml) : passer -f explicitement. +# docker compose -f docker-compose.webapp.yml \ +# -f docker-compose.webapp.override.yml up -d --build +# +# docker compose applique ce fichier par-dessus docker-compose.webapp.yml ; +# il suffit de la clé `labels`, le reste hérite du compose principal. Le +# webapp sert du HTTP ; Traefik y accède via le port 8973 du conteneur. +# +# Le domaine se définit par LIDAR_WEBAPP_HOST (dans un .env à côté du compose +# ou l'environnement) ; défaut lidar.local. Remplacer par le domaine réel, ou +# simplement écrire le domaine en dur dans la règle Host() ci-dessous. +services: + webapp: + labels: + # Activer le provider Docker pour ce conteneur. + - traefik.enable=true + # Router sur le domaine (adapter) vers le service webapp:8973. + - "traefik.http.routers.lidar-webapp.rule=Host(`${LIDAR_WEBAPP_HOST:-lidar.local}`)" + # Entrée HTTPS (443) + TLS avec le certificat porté par Traefik. + - traefik.http.routers.lidar-webapp.entrypoints=websecure + - traefik.http.routers.lidar-webapp.tls=true + # Si un certresolver spécifique (ACME / Let's Encrypt…) plutôt que le + # certificat par défaut du proxy : + # - "traefik.http.routers.lidar-webapp.tls.certresolver=letsencrypt" + # Service explicite vers le conteneur (nom_docker:port) : + # - "traefik.http.services.lidar-webapp.loadbalancer.server.port=8973" diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml index f178f48..138bce3 100644 --- a/docker-compose.webapp.yml +++ b/docker-compose.webapp.yml @@ -18,9 +18,9 @@ services: ports: - "8973:8973" volumes: - # Copie locale des tuiles traitées, synchronisée par LIDAR_SYNC_CMD + # Cache local des tuiles, peuplé à la demande depuis LIDAR_GENERATION_URL - ./output:/data/output - # Clé SSH du rsync vers la machine de traitement (LIDAR_SYNC_CMD) + # Clé SSH du hôte (déploiement, git pull distant) si nécessaire # - ~/.ssh:/home/lidar/.ssh:ro # Configuration SSH (known_hosts) si nécessaire # - ~/.ssh/config:/home/lidar/.ssh/config:ro @@ -35,14 +35,16 @@ services: # - LIDAR_GENERATION_URL=http://192.168.1.50:8973 # Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement # - LIDAR_REMOTE_TOKEN=change-moi - # Ramène les tuiles traitées + sidecars DTM (les vignettes sont - # régénérées localement par /api/sync, ne pas les synchroniser) - # - LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ - # Cache local auto-entretenu : resynchronisation + vignettes toutes les - # N secondes (0/défaut = seulement à la demande, bouton ↻ ou /api/sync) - # - LIDAR_AUTO_SYNC_SECONDS=600 + # Les tuiles sont servies à la demande depuis LIDAR_GENERATION_URL + # (cache peuplé au fil des consultations) : plus de rsync ni de sync + # périodique ; le bouton ↻ / POST /api/sync déclenchent le rebuild. # Réseau autorisé à lancer les générations depuis la carte (défaut # 192.168.1.0/24 ; chaîne vide = restriction levée). Les autres clients # consultent la carte en lecture seule (boutons masqués, API 403). # - LIDAR_REGEN_CIDR=192.168.1.0/24 + # Le webapp sert du HTTP. Le TLS est terminé par le reverse proxy (Traefik, + # certificat porté par le proxy) : la configuration Traefik (routage HTTPS) + # vit dans le fichier override local docker-compose.webapp.override.yml + # (non versionné, sur le Pi) — cf. docs/DEPLOY_WEBAPP.md « Centrer la + # carte sur la position GPS » et docker-compose.webapp.override.yml.example. restart: unless-stopped diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index c4d2216..2ce2ff2 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -3,7 +3,8 @@ # Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les # webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent : # dessin d'une zone → téléchargement IGN + traitement GPU ici, les tuiles -# sont relues par la webapp via son LIDAR_SYNC_CMD (rsync). +# sont servies à la demande par la webapp (cache peuplé au fil des +# consultations). # # docker compose -f docker-compose.worker.yml up -d --build # docker compose -f docker-compose.worker.yml logs -f worker diff --git a/docs/DEPLOY_WEBAPP.md b/docs/DEPLOY_WEBAPP.md index a0b2a8d..14b4f68 100644 --- a/docs/DEPLOY_WEBAPP.md +++ b/docs/DEPLOY_WEBAPP.md @@ -10,7 +10,7 @@ Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp) │ sert carte + vignettes (régénérées localement) │ /api/generate, /api/preview, /api/status │ └─ transmis à ──▶ machine de traitement - │ /api/sync : rsync output/ ──◀── machine puissante + │ /api/sync : rebuild local (tuiles servies à la demande) ▼ Machine de traitement (image complète, docker-compose.worker.yml service `worker`) : @@ -21,7 +21,7 @@ Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp) Le service `worker` joue le rôle de générateur : il accepte les demandes de génération envoyées par les webapp distantes (téléchargement IGN + -traitement PDAL/GPU) et expose les tuiles produites pour le rsync. +traitement PDAL/GPU) et expose les tuiles produites, servies à la demande par les webapp. ```bash docker compose -f docker-compose.worker.yml up -d --build # API sur http://:8973 @@ -54,8 +54,8 @@ environment: - **Docker + plugin compose** : installation officielle [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) (tester avec `docker compose version`). -- **rsync/ssh côté client** : inutile sur l'hôte — ils sont dans l'image — - mais la **clé SSH** de l'hôte est montée dans le conteneur. +- **Clé SSH côté hôte** : montée dans le conteneur pour le déploiement et le + `git pull` distant (le rsync de tuiles, supprimé, n'en avait pas besoin). - **Espace disque** : prévoir la taille du cache de tuiles (compter la taille de `output/` sur la machine de traitement, ~quelques dizaines de Mo par dalle et par résolution). @@ -67,31 +67,37 @@ git clone ssh://git@git.example.fr:2222/code_public/lidar_rendu.git lidar cd lidar ``` -Le clone SSH utilise la même clé que le rsync (`~/.ssh`, voir ci-dessous) ; -si le serveur git n'est pas encore connu, faire une première connexion pour -accepter son empreinte. `output/` et `input/` sont ignorés par git : le -dépôt ne contient que le code, le cache de tuiles se remplit ensuite par -rsync (premier chargement, section 4). +Le clone SSH utilise la clé de l'hôte (`~/.ssh`, section 2). Si le serveur +git n'est pas encore connu, faire une première connexion pour accepter son +empreinte. `output/` et `input/` sont ignorés par git : le dépôt ne contient +que le code, le cache de tuiles se remplit à la demande depuis la machine de +traitement (section 4). -### 2. Accès SSH pour le rsync (la Pi tire les tuiles traitées) +### 2. Accès SSH (déploiement) + +Le rsync ayant été retiré, le SSH ne sert plus à ramener les tuiles (elles +sont servies à la demande, section 4). Il sert au déploiement et au support +: se connecter à la machine de traitement pour y inspecter `output/` ou y +déposer des tuiles manuellement. ```bash ssh-keygen -t ed25519 # si pas encore de clé -ssh-copy-id lidar@ # compte lecture sur output/ +ssh-copy-id lidar@ # compte sur la machine de traitement ssh lidar@ exit # 1re connexion : enregistre known_hosts ``` -La dernière commande évite le prompt « authenticity of host » pendant le -rsync (le conteneur ne peut pas répondre interactivement). +La dernière commande évite le prompt « authenticity of host » en connexion +non interactive (le conteneur ne peut pas répondre). -Sur la machine puissante, le dossier `output/` doit être lisible par ce -compte (ex. `/srv/lidar/output` si vous préférez un chemin dédié — adaptez -LIDAR_SYNC_CMD). +Le cache de tuiles du Pi se remplit à la demande depuis la machine de +traitement ; ce compte SSH sert à l'inspecter ou à y déposer des tuiles +manuellement si besoin. -Avec `serve-webapp.sh` et `run.sh`, `~/.ssh` (clé + known_hosts) est monté -automatiquement en lecture seule dans le conteneur ; avec `docker compose`, -le montage équivalent est à décommenter dans `docker-compose.webapp.yml` -(voir l'option c ci-dessous). +Avec `serve-webapp.sh` et `run.sh`, la clé SSH de l'hôte (`~/.ssh`, clé + +known_hosts) est montée automatiquement en lecture seule dans le conteneur ; +avec `docker compose`, le montage équivalent est à décommenter dans +`docker-compose.webapp.yml` (voir l'option c ci-dessous). Le wrapper local +`ssh` (agent forwarding) simplifie ces connexions. ### 3. Configurer et lancer @@ -103,12 +109,12 @@ configuration vit dans `webapp.env` (copie du modèle, non versionné) : ```bash cp webapp.env.example webapp.env -nano webapp.env # LIDAR_GENERATION_URL, LIDAR_SYNC_CMD, jeton... +nano webapp.env # LIDAR_GENERATION_URL, jeton... ./serve-webapp.sh # démarre (build au premier lancement) et attend le serveur ``` Sous-commandes : `stop`, `restart` (relit `webapp.env`), `status` (conteneur -+ état de la sync), `sync` (force rsync + vignettes), `logs`, `update` ++ état du rebuild), `sync` (rebuild + vignettes), `logs`, `update` (`git pull` + rebuild + redémarrage). Port hôte via `WEBAPP_PORT` dans `webapp.env` ou l'environnement (`WEBAPP_PORT=9000 ./serve-webapp.sh`). @@ -119,16 +125,13 @@ est monté automatiquement : ```bash LIDAR_GENERATION_URL=http://192.168.1.50:8973 \ LIDAR_REMOTE_TOKEN=un-secret-à-partager \ -LIDAR_SYNC_CMD="rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/" \ -LIDAR_AUTO_SYNC_SECONDS=600 \ ./run.sh --serve-webapp # port 8973, ou --serve-webapp 9000 ``` -`LIDAR_AUTO_SYNC_SECONDS` entretient le cache tout seul : toutes les N -secondes, rsync ramène les nouvelles tuiles et les vignettes manquantes -sont régénérées (les mtimes évitent tout recalcul inutile). Sans cette -variable, le cache se rafraîchit à la demande : bouton ↻, fin d'un run, -ou `POST /api/sync`. +Les tuiles sont servies à la demande : une image absente du cache local est +téléchargée depuis la machine de traitement au premier affichage, le cache +se remplit ainsi progressivement. Le bouton ↻, la fin d'un run ou `POST +/api/sync` déclenchent un rebuild de l'index et régénèrent les vignettes. `LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST /api/generate` : zones, sélection, complétion, régénération) aux clients @@ -147,14 +150,10 @@ génération sont masqués et l'API répond 403. - `LIDAR_GENERATION_URL` : `http://:8973` - `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine (inutile si aucun token là-bas) -- `LIDAR_SYNC_CMD` : la commande rsync qui copie `output/` distant vers - `/data/output/` local, ex : +- le cache local se remplit à la demande depuis `LIDAR_GENERATION_URL` (pas + de `LIDAR_SYNC_CMD`) -```yaml -- LIDAR_SYNC_CMD=rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/ -``` - -Décommenter aussi le montage de la clé SSH (rsync) : +Décommenter aussi le montage de la clé SSH (déploiement) : ```yaml volumes: @@ -191,6 +190,52 @@ curl -X POST http://:8973/api/sync (puis attendre la fin : `curl http://:8973/api/sync` → `"running": false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes). +### 5. Centrer la carte sur la position GPS (téléphone) + +Le bouton **⌖** (barre d'outils de la carte, à côté du recadrage) recentre la +carte sur la position GPS du téléphone et y dessine un marqueur vert. + +Les navigateurs n'exposent l'API Geolocation qu'en **contexte sécurisé** +(HTTPS). Servie en `http://:8973`, la carte ne peut donc pas obtenir la +position sur téléphone : le bouton affiche alors « connexion sécurisée (HTTPS) +requise ». + +**TLS terminé par le proxy (cas d'usage ici)** — le certificat est porté par +**Traefik** : le webapp sert du HTTP, Traefik expose `https://` et y +applique son certificat (valable, sans avertissement). Le téléphone se connecte +sur l'URL Traefik → contexte sécurisé → le bouton ⌖ fonctionne. + +La config Traefik (routage HTTPS) vit dans le fichier **override** local +`docker-compose.webapp.override.yml` (non versionné, sur le Pi) — Compose le +merge par-dessus `docker-compose.webapp.yml`. Le modèle versionné +`docker-compose.webapp.override.yml.example` fournit les labels (provider +Docker) qui routent `Host(\`${LIDAR_WEBAPP_HOST}\`)` vers le service, en +`websecure` (443) + TLS avec le certificat porté par Traefik. Sur le Pi : + +```bash +cp docker-compose.webapp.override.yml.example docker-compose.webapp.override.yml +# adapter le domaine : LIDAR_WEBAPP_HOST=carte.example.fr (dans un .env à +# côté du compose, l'environnement, ou en dur dans la règle Host()) +# docker compose n'auto-charge PAS un override au nom custom (seulement +# docker-compose.override.yml) → passer -f explicitement : +docker compose -f docker-compose.webapp.yml \ + -f docker-compose.webapp.override.yml up -d --build +``` + +Adapter si le proxy n'est pas en `websecure` ou que le provider n'est pas +Docker : changer `entrypoints` / ajouter +`traefik.http.routers.lidar-webapp.tls.certresolver=…`, ou déclarer le +service directement dans la config Traefik. Le webapp est transparent au +proxy (URLs relatives) et remonte l'IP réelle via `X-Forwarded-For` pour la +restriction `LIDAR_REGEN_CIDR` : l'IP du téléphone doit y figurer pour lancer +des générations. + +> **Sans Traefik** — servir la carte en HTTPS avec un certificat auto-signé : +> `./make-tls-cert.sh` crée `tls/webapp.{crt,key}`, à monter dans le +> conteneur avec `LIDAR_SSL_CERTFILE` / `LIDAR_SSL_KEYFILE` (le chemin +> `serve-webapp.sh` le fait automatiquement). Le téléphone accepte alors +> l'avertissement « certificat non fiable » une fois. + ## Mise à jour Avec `serve-webapp.sh`, une seule commande (`git pull`, rebuild de l'image, @@ -200,7 +245,7 @@ redémarrage) : ./serve-webapp.sh update ``` -Sinon, sur le Pi, après un `git pull` (ou rsync du code) : +Sinon, sur le Pi, après un `git pull` : ```bash docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage @@ -212,15 +257,14 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles ## Dépannage -- **État d'une sync** : `curl http://:8973/api/sync` → `running`, - `phase` (`sync` puis `index`) et `error` (dernière erreur, ex. fin de - journal rsync en cas d'échec). Les logs du conteneur : +- **État d'un rebuild** : `curl http://:8973/api/sync` → `running`, + `phase` (`index`) et `error` (dernière erreur). Les logs du conteneur : `docker logs lidar-webapp` (ou `docker compose -f docker-compose.webapp.yml logs -f webapp`). -- **`Permission denied (publickey)` pendant la sync** : refaire - `ssh-copy-id lidar@` et une connexion manuelle pour - renseigner `known_hosts` ; vérifier que `~/.ssh` est monté dans le - conteneur (automatique avec `run.sh`, à décommenter en compose). +- **`Permission denied (publickey)` en SSH** : refaire + `ssh-copy-id lidar@` et une connexion manuelle pour renseigner + `known_hosts` ; vérifier que `~/.ssh` est monté dans le conteneur + (automatique avec `run.sh`, à décommenter en compose). - **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du client n'est dans aucun des réseaux de `LIDAR_REGEN_CIDR` (défaut : localhost + plages privées). Adapter la variable ou la vider pour lever @@ -244,11 +288,10 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles à la machine de traitement, qui télécharge les dalles IGN puis les traite. La file de génération (progression tuile par tuile) est lue depuis la machine distante en direct. -- À la fin du run, le navigateur appelle `/api/sync` : rsync ramène les - images, le Pi régénère vignettes/sous-tuiles/index.html, puis recharge la - carte. Le bouton ↻ relance le même cycle à tout moment. Avec - `LIDAR_AUTO_SYNC_SECONDS`, le conteneur webapp seul maintient aussi son - cache périodiquement (utile quand personne ne consulte la carte). +- À la fin du run, le navigateur appelle `/api/sync` : les images produites + sont servies à la demande depuis la machine de traitement, le Pi régénère + vignettes/sous-tuiles/index.html, puis recharge la carte. Le bouton ↻ + relance le même cycle à tout moment. - Sans machine de traitement configurée, `./run.sh --serve-webapp` sert la carte en lecture seule depuis `output/` (cache figé, aucun traitement possible depuis l'interface). @@ -266,7 +309,7 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles ## Références - `lidar_pipeline/webapp.py` : proxy distant (`LIDAR_GENERATION_URL`), - `/api/sync`, cache local périodique (`LIDAR_AUTO_SYNC_SECONDS`), jeton + `/api/sync` (rebuild de l'index), cache local à la demande, jeton `LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`, restriction des générations au réseau local (`LIDAR_REGEN_CIDR`). - `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli diff --git a/lidar_pipeline/index.py b/lidar_pipeline/index.py index d9c701d..f5f00e9 100644 --- a/lidar_pipeline/index.py +++ b/lidar_pipeline/index.py @@ -1332,9 +1332,11 @@ _HTML_TEMPLATE = """ +
Zoom
+
Molette : zoom · Clic-glisser : déplacer
Clic sur tuile : infos de génération @@ -1618,6 +1620,7 @@ body.zooming .ltile { will-change: transform; } #mapTools { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; } #mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; } #btnMenu { display: none; } +#btnGps.busy { color: var(--accent); border-color: var(--accent); } /* --- Puces d'état (bas d'écran) --- */ .chip { @@ -1627,6 +1630,10 @@ body.zooming .ltile { will-change: transform; } font-size: 11px; color: var(--muted); } #zoomIndicator { bottom: 12px; left: var(--left-ui); font-variant-numeric: tabular-nums; } +#gpsToast { + top: 54px; left: 50%; transform: translateX(-50%); + max-width: 80vw; text-align: center; line-height: 1.5; +} #hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; } /* --- Onglet Génération --- */ @@ -2351,6 +2358,53 @@ document.getElementById('zoomReset').addEventListener('click', () => { history.replaceState(null, '', location.pathname + location.search); if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); }); + +// ============================================================ +// Position GPS du téléphone : centrer la carte dessus +// ============================================================ +const gpsBtn = document.getElementById('btnGps'); +const gpsToast = document.getElementById('gpsToast'); +let gpsToastTimer = null; +let gpsMarker = null; +function gpsMsg(text, ms) { + gpsToast.textContent = text; + gpsToast.hidden = false; + clearTimeout(gpsToastTimer); + gpsToastTimer = setTimeout(() => { gpsToast.hidden = true; }, ms || 4000); +} +gpsBtn.addEventListener('click', () => { + if (!('geolocation' in navigator)) { + gpsMsg('Géolocalisation non prise en charge par ce navigateur ' + + '(connexion sécurisée HTTPS requise).', 6000); + return; + } + gpsBtn.classList.add('busy'); + try { + navigator.geolocation.getCurrentPosition(pos => { + gpsBtn.classList.remove('busy'); + const ll = [pos.coords.latitude, pos.coords.longitude]; + map.setView(ll, Math.max(map.getZoom(), 16)); + if (gpsMarker) gpsMarker.remove(); + gpsMarker = L.circleMarker(ll, { + radius: 5, color: '#39d98a', fillColor: '#39d98a', fillOpacity: 0.9 + }).addTo(map); + if (pos.coords.accuracy) { + gpsMarker.bindTooltip('Précision GPS ~' + Math.round(pos.coords.accuracy) + ' m'); + } + gpsMsg('Carte centrée sur votre position GPS.'); + }, err => { + gpsBtn.classList.remove('busy'); + let why = 'Aucune position reçue'; + if (err && err.code === 1) why = 'Autorisation de localisation refusée'; + else if (err && err.code === 2) why = 'Position momentanément indisponible'; + else if (err && err.code === 3) why = 'Délai de localisation dépassé'; + gpsMsg(why + '.', 6000); + }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }); + } catch (e) { + gpsBtn.classList.remove('busy'); + gpsMsg('La géolocalisation exige une connexion sécurisée (HTTPS).', 6000); + } +}); // will-change pendant l'animation de zoom uniquement : le scaling passe sur // le GPU, et les couches sont libérées juste après (mémoire vidéo). let zoomGpuTimer = null; diff --git a/lidar_pipeline/tests/test_webapp.py b/lidar_pipeline/tests/test_webapp.py index d3cf3a2..14532d8 100644 --- a/lidar_pipeline/tests/test_webapp.py +++ b/lidar_pipeline/tests/test_webapp.py @@ -686,63 +686,25 @@ def test_viz_step_labels_without_pipeline(): assert labels["pos_open"] == "Openness positive" -def test_sync_runs_command_then_rebuild(tmp_path, monkeypatch): - """POST /api/sync exécute LIDAR_SYNC_CMD puis build_index en arrière-plan.""" +def test_sync_is_rebuild_only(tmp_path, monkeypatch): + """/api/sync régénère l'index en arrière-plan (plus de rsync).""" import time as _time import lidar_pipeline.webapp as webapp import lidar_pipeline.index as index_mod calls = [] monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out)) - monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0") webapp._rebuild["done"] = None # ignore l'état d'un test précédent - assert webapp.sync_and_rebuild()["sync"] is True + assert webapp.sync_and_rebuild()["demarré"] is True for _ in range(200): s = webapp.rebuild_status() if s["done"] is not None and not s["running"]: break _time.sleep(0.05) - assert calls # l'index a été reconstruit après la synchronisation + assert calls # l'index a été reconstruit assert webapp.rebuild_status()["error"] is None assert webapp.rebuild_status()["done"] is not None -def test_sync_failure_reported_but_index_rebuilt(tmp_path, monkeypatch): - """Un rsync en échec est remonté dans l'état sans bloquer le rebuild.""" - import time as _time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - calls = [] - monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out)) - monkeypatch.setattr(webapp, "SYNC_CMD", "exit 3") - webapp._rebuild["done"] = None - webapp.sync_and_rebuild() - for _ in range(200): - s = webapp.rebuild_status() - if s["done"] is not None and not s["running"]: - break - _time.sleep(0.05) - assert calls # rebuild quand même (données locales éventuelles) - assert "rc=3" in webapp.rebuild_status()["error"] - - -def test_sync_without_cmd_is_rebuild_only(monkeypatch): - """Sans LIDAR_SYNC_CMD (machine locale), /api/sync = simple rebuild.""" - import time as _time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - calls = [] - monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out)) - monkeypatch.setattr(webapp, "SYNC_CMD", None) - webapp._rebuild["done"] = None - assert webapp.sync_and_rebuild()["sync"] is False - for _ in range(200): - s = webapp.rebuild_status() - if s["done"] is not None and not s["running"]: - break - _time.sleep(0.05) - assert calls and webapp.rebuild_status()["error"] is None - - def test_require_token(): """Le jeton (si défini) bloque les appels sans ou avec mauvais mauvais header.""" from fastapi import HTTPException @@ -851,25 +813,4 @@ def test_regen_cidr_accepts_comma_separated_list(): webapp.REGEN_CIDR = old -def test_auto_sync_once_skips_when_busy(monkeypatch): - """Le cycle de cache ne lance rien si un rebuild tourne déjà.""" - import time as _time - import lidar_pipeline.webapp as webapp - import lidar_pipeline.index as index_mod - calls = [] - monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out)) - monkeypatch.setattr(webapp, "SYNC_CMD", "exit 0") - # Un cycle démarre bien (sync + rebuild) - assert webapp._auto_sync_once() is True - for _ in range(200): - if not webapp.rebuild_status()["running"]: - break - _time.sleep(0.05) - assert calls - # Rebuild artificiellement occupé : le cycle suivant est sauté sans erreur - webapp._rebuild["running"] = True - try: - assert webapp._auto_sync_once() is False - assert len(calls) == 1 - finally: - webapp._rebuild["running"] = False + diff --git a/lidar_pipeline/webapp.py b/lidar_pipeline/webapp.py index a0b8a10..b2ee8ec 100644 --- a/lidar_pipeline/webapp.py +++ b/lidar_pipeline/webapp.py @@ -7,9 +7,8 @@ le téléchargement IGN). Endpoints : GET /api/status → état de la génération en cours (ou dernière terminée) GET /api/tiles → données de carte (index_tiles.json) pour l'affichage en direct ; ?stamp=X → réponse allégée si inchangé - POST /api/sync → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync - depuis la machine de traitement) puis régénérer la - carte en arrière-plan + POST /api/sync → régénérer l'index local (vignettes + carte) en + arrière-plan (les tuiles sont servies à la demande) POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option all_missing=true) toutes les dalles LHD présentes dans input/ qui manquent au moins une visualisation demandée. @@ -48,10 +47,11 @@ Architecture deux machines (cf. docs/DEPLOY_WEBAPP.md) : la webapp légère (sans PDAL/GPU, ex. Raspberry Pi) délègue la génération à la machine de traitement via LIDAR_GENERATION_URL — /api/generate, /api/preview et /api/status sont transmis tels quels au service distant, qui exécute la même -webapp avec le pipeline complet. Les résultats reviennent par la commande de -synchronisation LIDAR_SYNC_CMD (rsync), puis /api/sync régénère vignettes et -carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN -(webapp légère) protègent optionnellement les appels distants. +webapp avec le pipeline complet. Les images produites sont servies à la +demande depuis GENERATION_URL (cache local peuplé au fil des consultations) +puis /api/sync régénère vignettes et carte localement. LIDAR_API_TOKEN +(machine de traitement) + LIDAR_REMOTE_TOKEN (webapp légère) protègent +optionnellement les appels distants. """ import json @@ -90,9 +90,6 @@ GENERATION_URL = (os.environ.get("LIDAR_GENERATION_URL") or "").rstrip("/") # (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel. REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None API_TOKEN = os.environ.get("LIDAR_API_TOKEN") or None -# Commande shell qui ramène les tuiles traitées dans output/ local (rsync -# depuis la machine de traitement). Exécutée par /api/sync avant le rebuild. -SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None # Réseau autorisé à lancer la (re)génération des tuiles (/api/generate) : # liste de CIDR séparés par virgules, vide = restriction levée. Par défaut : # boucle locale + plages privées RFC1918 — couvre le LAN, la machine hôte @@ -105,10 +102,6 @@ SYNC_CMD = os.environ.get("LIDAR_SYNC_CMD") or None REGEN_CIDR = (os.environ.get( "LIDAR_REGEN_CIDR", "127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") or "").strip() -# Cache local périodique : relance la synchronisation + le rebuild toutes -# les N secondes (0 = désactivé). Sert au conteneur webapp seul, qui maintient -# sa copie des tuiles de la machine de traitement. -AUTO_SYNC_SECONDS = float(os.environ.get("LIDAR_AUTO_SYNC_SECONDS", "0") or 0) # Résolution générée par /api/generate (cf. _build_command) : 0,2 m seule, # la 0,5 m n'est plus produite. La détection des tuiles à compléter exige @@ -134,10 +127,6 @@ if GENERATION_URL: else: logger.info("Backend de génération des tuiles : local — consultation " "autonome du cache, sans LIDAR_GENERATION_URL") -if SYNC_CMD: - logger.info(f"Cache local des tuiles : LIDAR_SYNC_CMD" - + (f" (rafraîchi toutes les {AUTO_SYNC_SECONDS:g}s)" - if AUTO_SYNC_SECONDS > 0 else " (à la demande)")) # assets/ (CSS/JS de l'interface) est créé dès le démarrage pour que le monteur # statique soit actif même avant la première génération de l'index. @@ -145,11 +134,11 @@ _assets_dir = OUTPUT_DIR / "assets" _assets_dir.mkdir(parents=True, exist_ok=True) # --- Cache à la demande : la visualisation peuple le cache local ------------- -# Sans LIDAR_SYNC_CMD (pas de rsync), les images viennent de la machine de -# traitement par HTTP : une image absente du disque — ou plus ancienne que la -# version ?v= référencée (mtime ms de la source sur le worker) — est téléchar- -# gée depuis GENERATION_URL, écrite sur disque puis servie. Requêtes concur- -# rentes dédupliquées (verrou par fichier), flux réseau plafonné (sémaphore). +# Les images viennent de la machine de traitement par HTTP : une image absente +# du disque — ou plus ancienne que la version ?v= référencée (mtime ms de la +# source sur le worker) — est téléchargée depuis GENERATION_URL, écrite sur +# disque puis servie. Requêtes concurrentes dédupliquées (verrou par fichier), +# flux réseau plafonné (sémaphore). from fastapi.staticfiles import StaticFiles as _StaticFiles from starlette.concurrency import run_in_threadpool @@ -782,91 +771,46 @@ def tiles_data(stamp: Optional[float] = None): } -# --- Synchronisation + rebuild de l'index en arrière-plan ------------------- +# --- Rebuild de l'index en arrière-plan -------------------------------------- +# Les tuiles sont servies à la demande (cache peuplé depuis la machine de +# traitement) : /api/sync ne fait plus qu'en régénérer l'index local +# (vignettes + carte). _rebuild = {"running": False, "error": None, "done": None, "phase": None} -def _start_rebuild(with_sync): - """Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index. - - Sur la webapp légère, with_sync=True ramène d'abord les tuiles traitées - par la machine distante (rsync) ; l'échec éventuel de la synchronisation - est remonté dans l'état sans empêcher le rebuild des données locales. - """ +def _start_rebuild(): + """Lance en arrière-plan le rebuild de l'index (vignettes + carte).""" if _rebuild["running"]: - raise HTTPException(409, "une synchronisation/rebuild est déjà en cours") + raise HTTPException(409, "un rebuild est déjà en cours") def _run(): _rebuild["running"] = True _rebuild["error"] = None _rebuild["done"] = None - sync_error = None - if with_sync and SYNC_CMD: - _rebuild["phase"] = "sync" - try: - r = subprocess.run(SYNC_CMD, shell=True, capture_output=True, - text=True, timeout=4 * 3600) - if r.returncode != 0: - tail = (r.stderr or r.stdout or "").strip()[-500:] - sync_error = (f"synchronisation échouée (rc={r.returncode})" - + (f" : {tail}" if tail else "")) - except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état - sync_error = f"synchronisation échouée : {e}" _rebuild["phase"] = "index" try: from .index import build_index build_index(OUTPUT_DIR) except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état - _rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e) - else: - _rebuild["error"] = sync_error + _rebuild["error"] = str(e) finally: _rebuild["running"] = False _rebuild["phase"] = None _rebuild["done"] = time.time() threading.Thread(target=_run, daemon=True).start() - return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)} - - -def _auto_sync_once(): - """Un cycle de cache local : synchronisation + rebuild, si rien ne tourne. - - Retourne True si un cycle a été lancé, False si un rebuild était déjà en - cours (le prochain cycle rattrapera). - """ - if _rebuild["running"]: - return False - try: - _start_rebuild(with_sync=True) - return True - except HTTPException: - return False # course perdue contre un lancement manuel : tant pis - - -def _auto_sync_loop(): - """Boucle du cache local : synchronise les tuiles périodiquement.""" - while True: - time.sleep(AUTO_SYNC_SECONDS) - _auto_sync_once() - - -# Conteneur webapp seul (cache local des tuiles) : lancer la boucle de -# synchronisation en arrière-plan si demandé. Sans LIDAR_SYNC_CMD il n'y a -# rien à synchroniser — pas de boucle (juste le rebuild à la demande). -if AUTO_SYNC_SECONDS > 0 and SYNC_CMD: - threading.Thread(target=_auto_sync_loop, daemon=True, - name="cache-tuiles").start() + return {"demarré": True} @app.post("/api/sync", dependencies=[Depends(_require_token)]) def sync_and_rebuild(): - """Synchronise les tuiles (LIDAR_SYNC_CMD) puis régénère la carte en arrière-plan. + """Régénère l'index local (vignettes + carte) en arrière-plan. - Sans LIDAR_SYNC_CMD (machine de traitement locale), se réduit à un simple - rebuild de l'index — même comportement que /api/rebuild. + Les tuiles étant servies à la demande, il n'y a plus de synchronisation à + exécuter : /api/sync se réduit à un rebuild de l'index (même + comportement que /api/rebuild). """ - return _start_rebuild(with_sync=True) + return _start_rebuild() @app.get("/api/sync") @@ -877,7 +821,7 @@ def sync_status(): @app.post("/api/rebuild", dependencies=[Depends(_require_token)]) def rebuild_index(): """Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan.""" - return _start_rebuild(with_sync=False) + return _start_rebuild() @app.get("/api/rebuild") @@ -1090,9 +1034,9 @@ def export_tiles(req: ExportRequest): """Assemble des dalles adjacentes en image ou PDF (export.py). Local par nature : les tuiles assemblées sont celles du cache output/ - (la webapp légère possède sa copie via rsync) — aucune délégation à la - machine de traitement. Un seul export à la fois (CPU/mémoire limités sur - Raspberry Pi). + (la webapp légère les possède via le cache à la demande) — aucune + délégation à la machine de traitement. Un seul export à la fois + (CPU/mémoire limités sur Raspberry Pi). """ import re as _re from .export import EXPORT_FORMATS, build_export @@ -1153,4 +1097,13 @@ def export_file(name: str): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=PORT) + # HTTPS optionnel : la géolocalisation du navigateur (bouton ⌖, centrage + # GPS depuis un téléphone) n'est disponible qu'en contexte sécurisé. + # Définir LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE (certificat auto-signé) + # pour servir la carte en https://:PORT ; le téléphone accepte + # l'avertissement « certificat non fiable » puis le GPS fonctionne. + _ssl_cert = os.environ.get("LIDAR_SSL_CERTFILE") + _ssl_key = os.environ.get("LIDAR_SSL_KEYFILE") + _ssl_kw = ({"ssl_certfile": _ssl_cert, "ssl_keyfile": _ssl_key} + if _ssl_cert and _ssl_key else {}) + uvicorn.run(app, host="0.0.0.0", port=PORT, **_ssl_kw) diff --git a/make-tls-cert.sh b/make-tls-cert.sh new file mode 100755 index 0000000..e96bfc8 --- /dev/null +++ b/make-tls-cert.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Génère un certificat auto-signé pour servir la webapp en HTTPS. +# +# Pourquoi : l'API Geolocation des navigateurs (bouton ⌖ « centrer sur la +# position GPS du téléphone ») n'est disponible qu'en contexte sécurisé +# (HTTPS). Servir la carte en https://:PORT débloque le GPS sur +# téléphone ; le certificat auto-signé affiche un avertissement « non fiable » +# que l'on accepte une fois (l'usage est sur le LAN, pas exposé). +# +# Usage : +# ./make-tls-cert.sh # crée tls/webapp.crt + tls/webapp.key +# ./make-tls-cert.sh --force # régénère même si le certificat existe +# +# Le certificat est monté dans le conteneur (./tls → /app/tls) et activé par +# les variables LIDAR_SSL_CERTFILE / LIDAR_SSL_KEYFILE (cf. serve-webapp.sh +# et docs/DEPLOY_WEBAPP.md). +set -euo pipefail +cd "$(dirname "$0")" + +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 + +mkdir -p tls +# Garde-fou : si docker compose a déjà monté ./tls, Docker l'a créé root:root. +# En tant qu'utilisateur (non root), on ne peut pas écrire dedans. +if [ ! -w tls ]; then + echo "Erreur : tls/ n'est pas writable par $(id -un) (souvent créé root par Docker)." >&2 + echo "Corriger : sudo chown \"$(id -un)\" tls puis relancer $0" >&2 + exit 1 +fi +if [ "$FORCE" -eq 0 ] && [ -f tls/webapp.crt ] && [ -f tls/webapp.key ]; then + echo "Certificat auto-signé déjà présent : tls/webapp.crt + tls/webapp.key" + echo "Régénérer : $0 --force" +else + CN="${LIDAR_CERT_CN:-lidar-webapp}" + # SAN : nom local du conteneur + IP générique. Le téléphone se connecte + # par l'IP du Pi ; le SAN IP ne couvre pas cette IP dynamique d'où + # l'avertissement de nom, mais le certificat reste valable (10 ans). + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout tls/webapp.key -out tls/webapp.crt \ + -subj "/CN=${CN}" \ + -addext "subjectAltName=DNS:${CN},DNS:lidar-webapp,DNS:*.local,IP:0.0.0.0" \ + > /dev/null + chmod 600 tls/webapp.key + echo "Certificat auto-signé créé : tls/webapp.crt + tls/webapp.key (valable 10 ans, CN=${CN})" +fi + +echo +echo "Activation (webapp.env) :" +echo " LIDAR_SSL_CERTFILE=/app/tls/webapp.crt" +echo " LIDAR_SSL_KEYFILE=/app/tls/webapp.key" +echo "serve-webapp.sh monte ./tls → /app/tls et passe ces variables automatiquement." +echo "Puis : ./serve-webapp.sh restart — la carte répond sur https://:PORT." diff --git a/run.sh b/run.sh index 03928b4..aa4b5f7 100755 --- a/run.sh +++ b/run.sh @@ -25,7 +25,7 @@ # --serve-webapp [PORT] # Conteneur webapp seul (image légère) : carte servie depuis # le cache local de tuiles, génération déléguée à la machine -# de traitement (LIDAR_GENERATION_URL, LIDAR_SYNC_CMD) +# de traitement (LIDAR_GENERATION_URL), tuiles à la demande # -h Afficher l'aide complète set -e @@ -186,7 +186,7 @@ while [ $# -gt 0 ]; do echo " --serve-webapp [PORT]" echo " Conteneur webapp seul (image légère, sans PDAL/GPU) :" echo " carte servie depuis le cache local de tuiles, génération" - echo " déléguée via LIDAR_GENERATION_URL + LIDAR_SYNC_CMD" + echo " déléguée via LIDAR_GENERATION_URL (tuiles à la demande)" echo " (cf. docs/DEPLOY_WEBAPP.md)" echo " --test Exécuter les tests unitaires" echo " -h Afficher cette aide" @@ -299,38 +299,29 @@ fi # Mode --serve-webapp : conteneur webapp SEUL (image légère, sans PDAL/GPU). # La carte est servie depuis le cache local de tuiles (output/) ; la -# génération est déléguée à la machine de traitement et les tuiles reviennent -# par LIDAR_SYNC_CMD (rsync), rafraîchies aussi par LIDAR_AUTO_SYNC_SECONDS. +# génération est déléguée à la machine de traitement et les tuiles sont +# servies à la demande depuis LIDAR_GENERATION_URL (plus de rsync). # Variables passées telles quelles depuis l'environnement hôte : # LIDAR_GENERATION_URL machine de traitement (ex: http://192.168.1.50:8973) # LIDAR_REMOTE_TOKEN jeton si la machine exige LIDAR_API_TOKEN -# LIDAR_SYNC_CMD rsync qui remplit le cache local de tuiles -# LIDAR_AUTO_SYNC_SECONDS resynchronisation périodique (0 = off) # LIDAR_REGEN_CIDR réseau autorisé à lancer les générations # (défaut 192.168.1.0/24, vide = restriction levée) if [ "$WEBAPP_FLAG" -eq 1 ]; then build_webapp_image mkdir -p "$OUTPUT_DIR" - AUTO_SYNC_INFO="${LIDAR_AUTO_SYNC_SECONDS:-0}" echo "============================================" echo " Carte LiDAR — webapp seule (image légère)" echo "============================================" echo " http://127.0.0.1:${WEBAPP_SERVE_PORT}/" echo " Génération distante : ${LIDAR_GENERATION_URL:-non configurée (LIDAR_GENERATION_URL)}" - if [ -n "$LIDAR_SYNC_CMD" ]; then - echo " Cache local tuiles : LIDAR_SYNC_CMD$([ "${LIDAR_AUTO_SYNC_SECONDS:-0}" != "0" ] && echo " (auto toutes les ${AUTO_SYNC_INFO}s)")" - else - echo " Cache local tuiles : désactivé (définir LIDAR_SYNC_CMD pour rsync)" - fi + echo " Cache local tuiles : à la demande (LIDAR_GENERATION_URL)" echo "============================================" WEBAPP_ENV=() [ -n "$LIDAR_GENERATION_URL" ] && WEBAPP_ENV+=(-e LIDAR_GENERATION_URL="$LIDAR_GENERATION_URL") [ -n "$LIDAR_REMOTE_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN") [ -n "$LIDAR_API_TOKEN" ] && WEBAPP_ENV+=(-e LIDAR_API_TOKEN="$LIDAR_API_TOKEN") - [ -n "$LIDAR_SYNC_CMD" ] && WEBAPP_ENV+=(-e LIDAR_SYNC_CMD="$LIDAR_SYNC_CMD") - [ -n "${LIDAR_AUTO_SYNC_SECONDS:-}" ] && WEBAPP_ENV+=(-e LIDAR_AUTO_SYNC_SECONDS="$LIDAR_AUTO_SYNC_SECONDS") [ -n "${LIDAR_REGEN_CIDR:-}" ] && WEBAPP_ENV+=(-e LIDAR_REGEN_CIDR="$LIDAR_REGEN_CIDR") - # Clé SSH du rsync (LIDAR_SYNC_CMD) : montée en lecture seule si présente + # Clé SSH du hôte (déploiement, git pull distant) : montée en lecture seule si présente WEBAPP_SSH=() [ -d "${HOME}/.ssh" ] && WEBAPP_SSH+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro") exec docker run --rm --init \ diff --git a/serve-webapp.sh b/serve-webapp.sh index 2bd564e..4119c5e 100755 --- a/serve-webapp.sh +++ b/serve-webapp.sh @@ -7,17 +7,21 @@ # WEBAPP_PORT port hôte (défaut 8973) # LIDAR_GENERATION_URL machine de traitement (ex. http://192.168.1.50:8973) # LIDAR_REMOTE_TOKEN jeton si la machine exige LIDAR_API_TOKEN -# LIDAR_SYNC_CMD rsync qui remplit le cache local de tuiles -# LIDAR_AUTO_SYNC_SECONDS resynchronisation périodique (défaut 0 = off) # LIDAR_REGEN_CIDR réseau autorisé à lancer les générations # (défaut 192.168.1.0/24, vide = restriction levée) # +# GPS sur téléphone (optionnel) : l'API Geolocation exige une connexion +# sécurisée. ./make-tls-cert.sh crée un certificat auto-signé dans ./tls ; +# dès qu'il est présent, ce script sert la carte en HTTPS (https://:port) +# et le bouton ⌖ « centrer sur la position GPS » fonctionne sur téléphone +# (accepter l'avertissement « certificat non fiable » une fois). +# # Usage : # ./serve-webapp.sh [start] démarrer (arrière-plan, redémarrage auto) # ./serve-webapp.sh stop arrêter et supprimer le conteneur # ./serve-webapp.sh restart redémarrer (relit webapp.env) # ./serve-webapp.sh status état du conteneur et de la dernière sync -# ./serve-webapp.sh sync forcer sync rsync + régénération des vignettes +# ./serve-webapp.sh sync forcer un rebuild + régénération des vignettes # ./serve-webapp.sh logs suivre les logs du conteneur # ./serve-webapp.sh update git pull, rebuild de l'image, redémarrer set -e @@ -25,8 +29,6 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" IMAGE="lidar-webapp" CONTAINER="lidar-webapp" -WEBAPP_PORT="${WEBAPP_PORT:-8973}" -BASE_URL="http://127.0.0.1:${WEBAPP_PORT}" # Configuration locale (non versionnée) : webapp.env surcharge l'environnement if [ -f "${SCRIPT_DIR}/webapp.env" ]; then @@ -35,6 +37,22 @@ if [ -f "${SCRIPT_DIR}/webapp.env" ]; then . "${SCRIPT_DIR}/webapp.env" set +a fi +WEBAPP_PORT="${WEBAPP_PORT:-8973}" + +# HTTPS (requis pour la géolocalisation GPS depuis un téléphone) : l'API +# Geolocation n'existe qu'en contexte sécurisé. Si le certificat auto-signé +# ./tls (cf. ./make-tls-cert.sh) est présent, la carte est servie en +# https://:PORT ; le téléphone accepte l'avertissement « certificat non +# fiable » une fois. Sans certificat : HTTP (le bouton GPS est indisponible). +TLS_DIR="${SCRIPT_DIR}/tls" +if [ -f "${TLS_DIR}/webapp.crt" ] && [ -f "${TLS_DIR}/webapp.key" ]; then + SCHEME=https + KFLAG=" -k" # -k : les sondes locales tolèrent le certificat auto-signé +else + SCHEME=http + KFLAG="" +fi +BASE_URL="${SCHEME}://127.0.0.1:${WEBAPP_PORT}" CMD="${1:-start}" @@ -53,7 +71,7 @@ lan_ip() { cmd_start() { mkdir -p "${SCRIPT_DIR}/output" - if curl -fsS "${BASE_URL}/api/status" > /dev/null 2>&1; then + if curl -fsS${KFLAG} "${BASE_URL}/api/status" > /dev/null 2>&1; then echo "Le port ${WEBAPP_PORT} répond déjà (webapp déjà lancée ?)." >&2 echo "Arrêter l'existant, ou relancer avec un autre port : WEBAPP_PORT=9000 $0" >&2 exit 1 @@ -68,17 +86,19 @@ cmd_start() { if [ -n "${LIDAR_REMOTE_TOKEN:-}" ]; then ENV_ARGS+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN") fi - if [ -n "${LIDAR_SYNC_CMD:-}" ]; then - ENV_ARGS+=(-e LIDAR_SYNC_CMD="$LIDAR_SYNC_CMD") - fi - if [ -n "${LIDAR_AUTO_SYNC_SECONDS:-}" ]; then - ENV_ARGS+=(-e LIDAR_AUTO_SYNC_SECONDS="$LIDAR_AUTO_SYNC_SECONDS") - fi # Définie même vide (= restriction levée) ; absente = défaut webapp.py if [ -n "${LIDAR_REGEN_CIDR+x}" ]; then ENV_ARGS+=(-e LIDAR_REGEN_CIDR="${LIDAR_REGEN_CIDR:-}") fi - # Clé SSH du rsync (LIDAR_SYNC_CMD) : montée en lecture seule si présente + # Certificat auto-signé (HTTPS → GPS téléphone) : monté en lecture seule + # et activé via LIDAR_SSL_CERTFILE / LIDAR_SSL_KEYFILE (cf. make-tls-cert.sh). + TLS_ARGS=() + if [ "$SCHEME" = "https" ]; then + TLS_ARGS+=(-v "${TLS_DIR}:/app/tls:ro") + TLS_ARGS+=(-e LIDAR_SSL_CERTFILE=/app/tls/webapp.crt) + TLS_ARGS+=(-e LIDAR_SSL_KEYFILE=/app/tls/webapp.key) + fi + # Clé SSH du hôte (déploiement, git pull distant) : montée en lecture seule si présente SSH_ARGS=() if [ -d "${HOME}/.ssh" ]; then SSH_ARGS+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro") @@ -90,6 +110,7 @@ cmd_start() { --user 1000:1000 \ -p "${WEBAPP_PORT}:8973" \ -v "${SCRIPT_DIR}/output:/data/output" \ + "${TLS_ARGS[@]}" \ "${SSH_ARGS[@]}" \ -e LIDAR_OUTPUT_DIR=/data/output \ "${ENV_ARGS[@]}" \ @@ -97,7 +118,7 @@ cmd_start() { echo -n "Attente du serveur sur le port ${WEBAPP_PORT}..." UP=0 for _ in $(seq 1 30); do - if curl -fsS "${BASE_URL}/api/status" > /dev/null 2>&1; then + if curl -fsS${KFLAG} "${BASE_URL}/api/status" > /dev/null 2>&1; then UP=1 break fi @@ -113,18 +134,13 @@ cmd_start() { echo "============================================" echo " URL : ${BASE_URL}/" if [ -n "$(lan_ip)" ]; then - echo " URL (LAN) : http://$(lan_ip):${WEBAPP_PORT}/" + echo " URL (LAN) : ${SCHEME}://$(lan_ip):${WEBAPP_PORT}/" + fi + if [ "$SCHEME" = "https" ]; then + echo " HTTPS (GPS tél.) : actif — sur le téléphone, acceptez l'avertissement « certificat non fiable »" fi echo " Génération distante: ${LIDAR_GENERATION_URL:-non configurée}" - if [ -n "${LIDAR_SYNC_CMD:-}" ]; then - if [ -n "${LIDAR_AUTO_SYNC_SECONDS:-}" ] && [ "${LIDAR_AUTO_SYNC_SECONDS}" != "0" ]; then - echo " Cache local tuiles : rsync (auto toutes les ${LIDAR_AUTO_SYNC_SECONDS}s)" - else - echo " Cache local tuiles : rsync (à la demande)" - fi - else - echo " Cache local tuiles : désactivé (LIDAR_SYNC_CMD)" - fi + echo " Cache local tuiles : à la demande (LIDAR_GENERATION_URL)" echo "============================================" } @@ -139,7 +155,7 @@ cmd_status() { else echo "Conteneur : ${CONTAINER} arrêté" fi - if curl -fsS "${BASE_URL}/api/sync" 2> /dev/null; then + if curl -fsS${KFLAG} "${BASE_URL}/api/sync" 2> /dev/null; then echo "" else echo "Webapp : injoignable sur ${BASE_URL}" @@ -147,7 +163,7 @@ cmd_status() { } cmd_sync() { - CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "${BASE_URL}/api/sync" || true)" + CODE="$(curl -s${KFLAG} -o /dev/null -w '%{http_code}' -X POST "${BASE_URL}/api/sync" || true)" case "$CODE" in 200) echo "Sync lancée (suivi : ./serve-webapp.sh status, logs : ./serve-webapp.sh logs)" ;; 409) echo "Une sync est déjà en cours (suivi : ./serve-webapp.sh status)" ;; diff --git a/webapp-entrypoint.sh b/webapp-entrypoint.sh new file mode 100755 index 0000000..e2f9877 --- /dev/null +++ b/webapp-entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Point d'entrée du conteneur webapp : sert la carte via uvicorn. +# +# HTTPS optionnel (requis pour la géolocalisation GPS depuis un téléphone) : +# si LIDAR_SSL_CERTFILE + LIDAR_SSL_KEYFILE sont définis ET que les fichiers +# existent (certificat auto-signé, cf. ./make-tls-cert.sh), la carte est servie +# en https://:PORT et le bouton ⌖ fonctionne sur téléphone. Si les +# variables sont définies mais les fichiers absents (cert non encore généré), +# repli en HTTP avec un avertissement — la carte reste consultable, seul le +# GPS est indisponible. +set -euo pipefail +PORT="${LIDAR_PORT:-8973}" +SSL_ARGS=() +if [ -n "${LIDAR_SSL_CERTFILE:-}" ] && [ -n "${LIDAR_SSL_KEYFILE:-}" ]; then + if [ -f "${LIDAR_SSL_CERTFILE}" ] && [ -f "${LIDAR_SSL_KEYFILE}" ]; then + echo "Webapp servie en HTTPS (certificat ${LIDAR_SSL_CERTFILE}) — GPS actif sur téléphone" + SSL_ARGS=(--ssl-certfile "${LIDAR_SSL_CERTFILE}" --ssl-keyfile "${LIDAR_SSL_KEYFILE}") + else + echo "Avertissement : certificats TLS absents (${LIDAR_SSL_CERTFILE} / ${LIDAR_SSL_KEYFILE}) — service en HTTP." >&2 + echo "Générer le certificat puis relancer : ./make-tls-cert.sh && docker compose up -d --build" >&2 + fi +fi +exec python3 -m uvicorn lidar_pipeline.webapp:app --host 0.0.0.0 --port "${PORT}" ${SSL_ARGS[@]+"${SSL_ARGS[@]}"} diff --git a/webapp.env.example b/webapp.env.example index 61cb0f8..23eee17 100644 --- a/webapp.env.example +++ b/webapp.env.example @@ -12,16 +12,31 @@ # Jeton partagé si la machine de traitement définit LIDAR_API_TOKEN #LIDAR_REMOTE_TOKEN=un-secret-à-partager -# rsync qui ramène les tuiles traitées vers le cache local. -# Les vignettes (index_thumbs/, index_subtiles/) sont régénérées localement, -# ne pas les synchroniser. -#LIDAR_SYNC_CMD="rsync -a --delete --exclude=*.tif --exclude=.generation* --exclude=index_thumbs --exclude=index_subtiles lidar@192.168.1.50:/srv/lidar/output/ /data/output/" - -# Cache auto-entretenu : resync + vignettes toutes les N secondes (0 = off) -#LIDAR_AUTO_SYNC_SECONDS=600 +# Les tuiles sont servies à la demande depuis LIDAR_GENERATION_URL (cache +# local peuplé au fil des consultations) : plus de rsync ni de sync périodique. # Réseaux autorisés à lancer les générations depuis la carte # (liste de CIDR séparés par virgules ; défaut : localhost + plages # privées 127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 ; # vide pour lever la restriction) #LIDAR_REGEN_CIDR=192.168.1.0/24 + +# --- GPS sur téléphone --------------------------------------------------- +# L'API Geolocation (bouton ⌖ « centrer sur la position GPS ») exige un +# contexte sécurisé (HTTPS). Deux façons de l'activer : +# +# a. Traefik (recommandé) : le webapp sert du HTTP, Traefik expose +# https:// avec un certificat valable. La config Traefik est +# dans l'override local docker-compose.webapp.override.yml (non +# versionné) — cf. docker-compose.webapp.override.yml.example et +# docs/DEPLOY_WEBAPP.md. Rien à définir dans ce fichier. +# +# b. Sans Traefik (chemin serve-webapp.sh) : certificat auto-signé. +# 1. ./make-tls-cert.sh (crée tls/webapp.crt + tls/webapp.key) +# 2. ./serve-webapp.sh restart (sert en https://:PORT, monte ./tls +# et passe ces variables automatiquement). Accepter l'avertissement +# « certificat non fiable » sur le téléphone. Ces lignes n'ont d'effet +# que si le certificat est stocké ailleurs que dans ./tls : +#LIDAR_SSL_CERTFILE=/app/tls/webapp.crt +#LIDAR_SSL_KEYFILE=/app/tls/webapp.key +