Centrer la carte sur le GPS du téléphone (HTTPS terminé par le proxy)

Le bouton ⌖ recentre la carte sur la position GPS du téléphone. L'API
Geolocation exige un contexte sécurisé : la webapp sert désormais du HTTP
et le TLS est porté par le reverse proxy (Traefik, labels dans l'override
local) ; le webapp est transparent au proxy (URLs relatives, X-Forwarded-For
pour la restriction LIDAR_REGEN_CIDR). Ajoute le modèle d'override
docker-compose.webapp.override.yml.example, un rebuild d'index à la demande
et met à jour doc/déploiement ; les tuiles sont servies à la demande.
This commit is contained in:
Antoine Jacquin
2026-09-13 19:57:09 +02:00
parent dd1aca3cfe
commit e2533bac48
16 changed files with 407 additions and 279 deletions

3
.gitignore vendored
View File

@ -45,6 +45,9 @@ htmlcov/
.env.local .env.local
webapp.env 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) # Données et artefacts internes (jamais dans le dépôt)
data/ data/
.swival/ .swival/

View File

@ -4,7 +4,7 @@
- build: `docker build -t lidar-lidar .` - 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 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) - 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) - 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 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.<module>` - test file: `docker run --rm lidar-lidar python3 -m pytest -v --pyargs lidar_pipeline.tests.<module>`

View File

@ -11,10 +11,10 @@ FROM python:3.12-slim
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Europe/Paris ENV TZ=Europe/Paris
# rsync + client SSH : synchronisation des tuiles depuis la machine de # Client SSH : inspection/accès à la machine de traitement si besoin. La
# traitement (LIDAR_SYNC_CMD). La clé se monte dans /home/lidar/.ssh. # 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
rsync \
openssh-client \ openssh-client \
&& rm -rf /var/lib/apt/lists/* && 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 # Code : webapp.py + index.py seulement utilisés — les modules de traitement
# (dtm, visualizations...) sont copiés mais jamais importés par la webapp. # (dtm, visualizations...) sont copiés mais jamais importés par la webapp.
COPY lidar_pipeline/ ./lidar_pipeline/ COPY lidar_pipeline/ ./lidar_pipeline/
COPY webapp-entrypoint.sh /webapp-entrypoint.sh
RUN groupadd -g 1000 lidar && \ RUN groupadd -g 1000 lidar && \
useradd -u 1000 -g lidar -m -s /bin/bash lidar && \ useradd -u 1000 -g lidar -m -s /bin/bash lidar && \
mkdir -p /data/output /data/input && \ 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 \ ENV LIDAR_INPUT_DIR=/data/input \
LIDAR_OUTPUT_DIR=/data/output \ LIDAR_OUTPUT_DIR=/data/output \
@ -46,4 +48,6 @@ USER lidar
EXPOSE 8973 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"]

View File

@ -6,9 +6,8 @@
# génération sur le port hôte 8974 ; # génération sur le port hôte 8974 ;
# - webapp : interface légère (image Dockerfile.webapp, sans PDAL/GPU) — # - webapp : interface légère (image Dockerfile.webapp, sans PDAL/GPU) —
# carte sur le port hôte 8973, avec son PROPRE cache de tuiles # carte sur le port hôte 8973, avec son PROPRE cache de tuiles
# (./output-webapp) alimenté par rsync depuis ./output du # (./output-webapp) peuplé à la demande depuis le worker,
# worker (monté en lecture seule dans la webapp), comme le # comme le fait le Raspberry Pi.
# Raspberry Pi le fait par SSH vers la machine de traitement.
# #
# Intérêt en local : mettre à jour le code de l'interface (rebuild webapp) # 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. # sans toucher au worker ni aux générations en cours, et réciproquement.
@ -52,21 +51,14 @@ services:
- "8973:8973" - "8973:8973"
volumes: volumes:
# Cache local propre à la webapp (équivalent du disque du Raspberry # Cache local propre à la webapp (équivalent du disque du Raspberry
# Pi) — PAS le ./output du worker : la séparation rendu/interface # Pi) — PAS le ./output du worker : la séparation rendu/interface doit
# doit être réelle, les tuiles arrivent par le rsync ci-dessous. # être réelle, les tuiles arrivent à la demande depuis le worker.
- ./output-webapp:/data/output - ./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: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
- LIDAR_OUTPUT_DIR=/data/output - 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 - 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 restart: unless-stopped

View File

@ -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://<domaine> — 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"

View File

@ -18,9 +18,9 @@ services:
ports: ports:
- "8973:8973" - "8973:8973"
volumes: 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 - ./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 # - ~/.ssh:/home/lidar/.ssh:ro
# Configuration SSH (known_hosts) si nécessaire # Configuration SSH (known_hosts) si nécessaire
# - ~/.ssh/config:/home/lidar/.ssh/config:ro # - ~/.ssh/config:/home/lidar/.ssh/config:ro
@ -35,14 +35,16 @@ services:
# - LIDAR_GENERATION_URL=http://192.168.1.50:8973 # - LIDAR_GENERATION_URL=http://192.168.1.50:8973
# Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement # Jeton partagé si LIDAR_API_TOKEN est défini sur la machine de traitement
# - LIDAR_REMOTE_TOKEN=change-moi # - LIDAR_REMOTE_TOKEN=change-moi
# Ramène les tuiles traitées + sidecars DTM (les vignettes sont # Les tuiles sont servies à la demande depuis LIDAR_GENERATION_URL
# régénérées localement par /api/sync, ne pas les synchroniser) # (cache peuplé au fil des consultations) : plus de rsync ni de sync
# - 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/ # périodique ; le bouton ↻ / POST /api/sync déclenchent le rebuild.
# 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
# Réseau autorisé à lancer les générations depuis la carte (défaut # 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 # 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). # consultent la carte en lecture seule (boutons masqués, API 403).
# - LIDAR_REGEN_CIDR=192.168.1.0/24 # - 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 restart: unless-stopped

View File

@ -3,7 +3,8 @@
# Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les # Tourne sur la machine puissante (GPU + PDAL) et expose l'API que les
# webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent : # webapp distantes (Raspberry Pi, docker-compose.webapp.yml) appellent :
# dessin d'une zone → téléchargement IGN + traitement GPU ici, les tuiles # 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 up -d --build
# docker compose -f docker-compose.worker.yml logs -f worker # docker compose -f docker-compose.worker.yml logs -f worker

View File

@ -10,7 +10,7 @@ Navigateur ──HTTP──▶ Raspberry Pi (image légère, Dockerfile.webapp)
│ sert carte + vignettes (régénérées localement) │ sert carte + vignettes (régénérées localement)
│ /api/generate, /api/preview, /api/status │ /api/generate, /api/preview, /api/status
│ └─ transmis à ──▶ machine de traitement │ └─ transmis à ──▶ machine de traitement
│ /api/sync : rsync output/ ──◀── machine puissante │ /api/sync : rebuild local (tuiles servies à la demande)
▼ ▼
Machine de traitement (image complète, Machine de traitement (image complète,
docker-compose.worker.yml service `worker`) : 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 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 + 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 ```bash
docker compose -f docker-compose.worker.yml up -d --build # API sur http://<ip-machine>:8973 docker compose -f docker-compose.worker.yml up -d --build # API sur http://<ip-machine>:8973
@ -54,8 +54,8 @@ environment:
- **Docker + plugin compose** : installation officielle - **Docker + plugin compose** : installation officielle
[docs.docker.com/engine/install](https://docs.docker.com/engine/install/) [docs.docker.com/engine/install](https://docs.docker.com/engine/install/)
(tester avec `docker compose version`). (tester avec `docker compose version`).
- **rsync/ssh côté client** : inutile sur l'hôte — ils sont dans l'image — - **Clé SSH côté hôte** : montée dans le conteneur pour le déploiement et le
mais la **clé SSH** de l'hôte est montée dans le conteneur. `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 - **Espace disque** : prévoir la taille du cache de tuiles (compter la
taille de `output/` sur la machine de traitement, ~quelques dizaines de taille de `output/` sur la machine de traitement, ~quelques dizaines de
Mo par dalle et par résolution). 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 cd lidar
``` ```
Le clone SSH utilise la même clé que le rsync (`~/.ssh`, voir ci-dessous) ; Le clone SSH utilise la clé de l'hôte (`~/.ssh`, section 2). Si le serveur
si le serveur git n'est pas encore connu, faire une première connexion pour git n'est pas encore connu, faire une première connexion pour accepter son
accepter son empreinte. `output/` et `input/` sont ignorés par git : le empreinte. `output/` et `input/` sont ignorés par git : le dépôt ne contient
dépôt ne contient que le code, le cache de tuiles se remplit ensuite par que le code, le cache de tuiles se remplit à la demande depuis la machine de
rsync (premier chargement, section 4). 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 ```bash
ssh-keygen -t ed25519 # si pas encore de clé ssh-keygen -t ed25519 # si pas encore de clé
ssh-copy-id lidar@<ip-machine> # compte lecture sur output/ ssh-copy-id lidar@<ip-machine> # compte sur la machine de traitement
ssh lidar@<ip-machine> exit # 1re connexion : enregistre known_hosts ssh lidar@<ip-machine> exit # 1re connexion : enregistre known_hosts
``` ```
La dernière commande évite le prompt « authenticity of host » pendant le La dernière commande évite le prompt « authenticity of host » en connexion
rsync (le conteneur ne peut pas répondre interactivement). non interactive (le conteneur ne peut pas répondre).
Sur la machine puissante, le dossier `output/` doit être lisible par ce Le cache de tuiles du Pi se remplit à la demande depuis la machine de
compte (ex. `/srv/lidar/output` si vous préférez un chemin dédié — adaptez traitement ; ce compte SSH sert à l'inspecter ou à y déposer des tuiles
LIDAR_SYNC_CMD). manuellement si besoin.
Avec `serve-webapp.sh` et `run.sh`, `~/.ssh` (clé + known_hosts) est monté Avec `serve-webapp.sh` et `run.sh`, la clé SSH de l'hôte (`~/.ssh`, clé +
automatiquement en lecture seule dans le conteneur ; avec `docker compose`, known_hosts) est montée automatiquement en lecture seule dans le conteneur ;
le montage équivalent est à décommenter dans `docker-compose.webapp.yml` avec `docker compose`, le montage équivalent est à décommenter dans
(voir l'option c ci-dessous). `docker-compose.webapp.yml` (voir l'option c ci-dessous). Le wrapper local
`ssh` (agent forwarding) simplifie ces connexions.
### 3. Configurer et lancer ### 3. Configurer et lancer
@ -103,12 +109,12 @@ configuration vit dans `webapp.env` (copie du modèle, non versionné) :
```bash ```bash
cp webapp.env.example webapp.env 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 ./serve-webapp.sh # démarre (build au premier lancement) et attend le serveur
``` ```
Sous-commandes : `stop`, `restart` (relit `webapp.env`), `status` (conteneur 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 (`git pull` + rebuild + redémarrage). Port hôte via `WEBAPP_PORT` dans
`webapp.env` ou l'environnement (`WEBAPP_PORT=9000 ./serve-webapp.sh`). `webapp.env` ou l'environnement (`WEBAPP_PORT=9000 ./serve-webapp.sh`).
@ -119,16 +125,13 @@ est monté automatiquement :
```bash ```bash
LIDAR_GENERATION_URL=http://192.168.1.50:8973 \ LIDAR_GENERATION_URL=http://192.168.1.50:8973 \
LIDAR_REMOTE_TOKEN=un-secret-à-partager \ 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 ./run.sh --serve-webapp # port 8973, ou --serve-webapp 9000
``` ```
`LIDAR_AUTO_SYNC_SECONDS` entretient le cache tout seul : toutes les N Les tuiles sont servies à la demande : une image absente du cache local est
secondes, rsync ramène les nouvelles tuiles et les vignettes manquantes téléchargée depuis la machine de traitement au premier affichage, le cache
sont régénérées (les mtimes évitent tout recalcul inutile). Sans cette se remplit ainsi progressivement. Le bouton ↻, la fin d'un run ou `POST
variable, le cache se rafraîchit à la demande : bouton ↻, fin d'un run, /api/sync` déclenchent un rebuild de l'index et régénèrent les vignettes.
ou `POST /api/sync`.
`LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST `LIDAR_REGEN_CIDR` restreint le lancement des générations (`POST
/api/generate` : zones, sélection, complétion, régénération) aux clients /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://<ip-machine>:8973` - `LIDAR_GENERATION_URL` : `http://<ip-machine>:8973`
- `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine - `LIDAR_REMOTE_TOKEN` : la valeur de `LIDAR_API_TOKEN` de la machine
(inutile si aucun token là-bas) (inutile si aucun token là-bas)
- `LIDAR_SYNC_CMD` : la commande rsync qui copie `output/` distant vers - le cache local se remplit à la demande depuis `LIDAR_GENERATION_URL` (pas
`/data/output/` local, ex : de `LIDAR_SYNC_CMD`)
```yaml Décommenter aussi le montage de la clé SSH (déploiement) :
- 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) :
```yaml ```yaml
volumes: volumes:
@ -191,6 +190,52 @@ curl -X POST http://<ip-pi>:8973/api/sync
(puis attendre la fin : `curl http://<ip-pi>:8973/api/sync` → `"running": (puis attendre la fin : `curl http://<ip-pi>:8973/api/sync` → `"running":
false`.) Le bouton ↻ de la carte fait la même chose (sync + vignettes). 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://<ip-pi>: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://<domaine>` 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 ## Mise à jour
Avec `serve-webapp.sh`, une seule commande (`git pull`, rebuild de l'image, Avec `serve-webapp.sh`, une seule commande (`git pull`, rebuild de l'image,
@ -200,7 +245,7 @@ redémarrage) :
./serve-webapp.sh update ./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 ```bash
docker compose -f docker-compose.webapp.yml up -d --build # rebuild + redémarrage 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 ## Dépannage
- **État d'une sync** : `curl http://<ip-pi>:8973/api/sync` → `running`, - **État d'un rebuild** : `curl http://<ip-pi>:8973/api/sync` → `running`,
`phase` (`sync` puis `index`) et `error` (dernière erreur, ex. fin de `phase` (`index`) et `error` (dernière erreur). Les logs du conteneur :
journal rsync en cas d'échec). Les logs du conteneur :
`docker logs lidar-webapp` (ou `docker compose -f docker-compose.webapp.yml `docker logs lidar-webapp` (ou `docker compose -f docker-compose.webapp.yml
logs -f webapp`). logs -f webapp`).
- **`Permission denied (publickey)` pendant la sync** : refaire - **`Permission denied (publickey)` en SSH** : refaire
`ssh-copy-id lidar@<ip-machine>` et une connexion manuelle pour `ssh-copy-id lidar@<ip-machine>` et une connexion manuelle pour renseigner
renseigner `known_hosts` ; vérifier que `~/.ssh` est monté dans le `known_hosts` ; vérifier que `~/.ssh` est monté dans le conteneur
conteneur (automatique avec `run.sh`, à décommenter en compose). (automatique avec `run.sh`, à décommenter en compose).
- **Boutons de génération masqués / `403` sur `/api/generate`** : l'IP du - **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 : 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 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 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 La file de génération (progression tuile par tuile) est lue depuis la
machine distante en direct. machine distante en direct.
- À la fin du run, le navigateur appelle `/api/sync` : rsync ramène les - À la fin du run, le navigateur appelle `/api/sync` : les images produites
images, le Pi régénère vignettes/sous-tuiles/index.html, puis recharge la sont servies à la demande depuis la machine de traitement, le Pi régénère
carte. Le bouton ↻ relance le même cycle à tout moment. Avec vignettes/sous-tuiles/index.html, puis recharge la carte. Le bouton ↻
`LIDAR_AUTO_SYNC_SECONDS`, le conteneur webapp seul maintient aussi son relance le même cycle à tout moment.
cache périodiquement (utile quand personne ne consulte la carte).
- Sans machine de traitement configurée, `./run.sh --serve-webapp` sert la - Sans machine de traitement configurée, `./run.sh --serve-webapp` sert la
carte en lecture seule depuis `output/` (cache figé, aucun traitement carte en lecture seule depuis `output/` (cache figé, aucun traitement
possible depuis l'interface). possible depuis l'interface).
@ -266,7 +309,7 @@ chaque lancement ; il suffit de relancer la commande. Le cache de tuiles
## Références ## Références
- `lidar_pipeline/webapp.py` : proxy distant (`LIDAR_GENERATION_URL`), - `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 `LIDAR_API_TOKEN`/`LIDAR_REMOTE_TOKEN`, restriction des générations au
réseau local (`LIDAR_REGEN_CIDR`). réseau local (`LIDAR_REGEN_CIDR`).
- `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli - `lidar_pipeline/index.py` : vignettes/index sans GDAL (pyproj ou repli

View File

@ -1332,9 +1332,11 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
<button id="zoomIn" title="Zoomer">+</button> <button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button> <button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button> <button id="zoomReset" title="Recadrer sur l'ensemble des tuiles">⤢</button>
<button id="btnGps" title="Centrer la carte sur la position GPS du téléphone">⌖</button>
</nav> </nav>
<div id="zoomIndicator" class="chip">Zoom</div> <div id="zoomIndicator" class="chip">Zoom</div>
<div id="gpsToast" class="chip" hidden></div>
<div id="hint" class="chip"> <div id="hint" class="chip">
Molette : zoom · Clic-glisser : déplacer<br> Molette : zoom · Clic-glisser : déplacer<br>
Clic sur tuile : infos de génération 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 { top: 12px; right: 12px; display: flex; gap: 6px; padding: 6px; }
#mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; } #mapTools button { width: 32px; height: 32px; padding: 0; font-size: 16px; }
#btnMenu { display: none; } #btnMenu { display: none; }
#btnGps.busy { color: var(--accent); border-color: var(--accent); }
/* --- Puces d'état (bas d'écran) --- */ /* --- Puces d'état (bas d'écran) --- */
.chip { .chip {
@ -1627,6 +1630,10 @@ body.zooming .ltile { will-change: transform; }
font-size: 11px; color: var(--muted); font-size: 11px; color: var(--muted);
} }
#zoomIndicator { bottom: 12px; left: var(--left-ui); font-variant-numeric: tabular-nums; } #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; } #hint { bottom: 12px; right: 12px; text-align: right; line-height: 1.5; }
/* --- Onglet Génération --- */ /* --- Onglet Génération --- */
@ -2351,6 +2358,53 @@ document.getElementById('zoomReset').addEventListener('click', () => {
history.replaceState(null, '', location.pathname + location.search); history.replaceState(null, '', location.pathname + location.search);
if (gridBounds) map.fitBounds(gridBounds, { padding: [24, 24] }); 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 // 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). // le GPU, et les couches sont libérées juste après (mémoire vidéo).
let zoomGpuTimer = null; let zoomGpuTimer = null;

View File

@ -686,63 +686,25 @@ def test_viz_step_labels_without_pipeline():
assert labels["pos_open"] == "Openness positive" assert labels["pos_open"] == "Openness positive"
def test_sync_runs_command_then_rebuild(tmp_path, monkeypatch): def test_sync_is_rebuild_only(tmp_path, monkeypatch):
"""POST /api/sync exécute LIDAR_SYNC_CMD puis build_index en arrière-plan.""" """/api/sync régénère l'index en arrière-plan (plus de rsync)."""
import time as _time import time as _time
import lidar_pipeline.webapp as webapp import lidar_pipeline.webapp as webapp
import lidar_pipeline.index as index_mod import lidar_pipeline.index as index_mod
calls = [] calls = []
monkeypatch.setattr(index_mod, "build_index", lambda out: calls.append(out)) 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 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): for _ in range(200):
s = webapp.rebuild_status() s = webapp.rebuild_status()
if s["done"] is not None and not s["running"]: if s["done"] is not None and not s["running"]:
break break
_time.sleep(0.05) _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()["error"] is None
assert webapp.rebuild_status()["done"] is not 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(): def test_require_token():
"""Le jeton (si défini) bloque les appels sans ou avec mauvais mauvais header.""" """Le jeton (si défini) bloque les appels sans ou avec mauvais mauvais header."""
from fastapi import HTTPException from fastapi import HTTPException
@ -851,25 +813,4 @@ def test_regen_cidr_accepts_comma_separated_list():
webapp.REGEN_CIDR = old 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

View File

@ -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/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 GET /api/tiles → données de carte (index_tiles.json) pour l'affichage
en direct ; ?stamp=X → réponse allégée si inchangé en direct ; ?stamp=X → réponse allégée si inchangé
POST /api/sync → synchroniser les tuiles (LIDAR_SYNC_CMD, ex. rsync POST /api/sync → régénérer l'index local (vignettes + carte) en
depuis la machine de traitement) puis régénérer la arrière-plan (les tuiles sont servies à la demande)
carte en arrière-plan
POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option POST /api/preview → cellules 1 km intersectant une bbox WGS84, OU (option
all_missing=true) toutes les dalles LHD présentes dans all_missing=true) toutes les dalles LHD présentes dans
input/ qui manquent au moins une visualisation demandée. 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 (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 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 /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 webapp avec le pipeline complet. Les images produites sont servies à la
synchronisation LIDAR_SYNC_CMD (rsync), puis /api/sync régénère vignettes et demande depuis GENERATION_URL (cache local peuplé au fil des consultations)
carte localement. LIDAR_API_TOKEN (machine de traitement) + LIDAR_REMOTE_TOKEN puis /api/sync régénère vignettes et carte localement. LIDAR_API_TOKEN
(webapp légère) protègent optionnellement les appels distants. (machine de traitement) + LIDAR_REMOTE_TOKEN (webapp légère) protègent
optionnellement les appels distants.
""" """
import json 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. # (LIDAR_REMOTE_TOKEN ici, LIDAR_API_TOKEN là-bas). Optionnel.
REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None REMOTE_TOKEN = os.environ.get("LIDAR_REMOTE_TOKEN") or None
API_TOKEN = os.environ.get("LIDAR_API_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) : # 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 : # 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 # 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( REGEN_CIDR = (os.environ.get(
"LIDAR_REGEN_CIDR", "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() "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, # 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 # la 0,5 m n'est plus produite. La détection des tuiles à compléter exige
@ -134,10 +127,6 @@ if GENERATION_URL:
else: else:
logger.info("Backend de génération des tuiles : local — consultation " logger.info("Backend de génération des tuiles : local — consultation "
"autonome du cache, sans LIDAR_GENERATION_URL") "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 # 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. # 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) _assets_dir.mkdir(parents=True, exist_ok=True)
# --- Cache à la demande : la visualisation peuple le cache local ------------- # --- Cache à la demande : la visualisation peuple le cache local -------------
# Sans LIDAR_SYNC_CMD (pas de rsync), les images viennent de la machine de # Les images viennent de la machine de traitement par HTTP : une image absente
# traitement par HTTP : une image absente du disque — ou plus ancienne que la # du disque — ou plus ancienne que la version ?v= référencée (mtime ms de la
# version ?v= référencée (mtime ms de la source sur le worker) — est téléchar- # source sur le worker) — est téléchargée depuis GENERATION_URL, écrite sur
# gée depuis GENERATION_URL, écrite sur disque puis servie. Requêtes concur- # disque puis servie. Requêtes concurrentes dédupliquées (verrou par fichier),
# rentes dédupliquées (verrou par fichier), flux réseau plafonné (sémaphore). # flux réseau plafonné (sémaphore).
from fastapi.staticfiles import StaticFiles as _StaticFiles from fastapi.staticfiles import StaticFiles as _StaticFiles
from starlette.concurrency import run_in_threadpool 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} _rebuild = {"running": False, "error": None, "done": None, "phase": None}
def _start_rebuild(with_sync): def _start_rebuild():
"""Lance en arrière-plan : LIDAR_SYNC_CMD (si demandé) puis build_index. """Lance en arrière-plan le rebuild de l'index (vignettes + carte)."""
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.
"""
if _rebuild["running"]: 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(): def _run():
_rebuild["running"] = True _rebuild["running"] = True
_rebuild["error"] = None _rebuild["error"] = None
_rebuild["done"] = 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" _rebuild["phase"] = "index"
try: try:
from .index import build_index from .index import build_index
build_index(OUTPUT_DIR) build_index(OUTPUT_DIR)
except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état except Exception as e: # noqa: BLE001 — remonté à l'UI via l'état
_rebuild["error"] = f"{sync_error} ; {e}" if sync_error else str(e) _rebuild["error"] = str(e)
else:
_rebuild["error"] = sync_error
finally: finally:
_rebuild["running"] = False _rebuild["running"] = False
_rebuild["phase"] = None _rebuild["phase"] = None
_rebuild["done"] = time.time() _rebuild["done"] = time.time()
threading.Thread(target=_run, daemon=True).start() threading.Thread(target=_run, daemon=True).start()
return {"demarré": True, "sync": bool(with_sync and SYNC_CMD)} return {"demarré": True}
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()
@app.post("/api/sync", dependencies=[Depends(_require_token)]) @app.post("/api/sync", dependencies=[Depends(_require_token)])
def sync_and_rebuild(): 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 Les tuiles étant servies à la demande, il n'y a plus de synchronisation à
rebuild de l'index — même comportement que /api/rebuild. 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") @app.get("/api/sync")
@ -877,7 +821,7 @@ def sync_status():
@app.post("/api/rebuild", dependencies=[Depends(_require_token)]) @app.post("/api/rebuild", dependencies=[Depends(_require_token)])
def rebuild_index(): def rebuild_index():
"""Régénère la carte (index.html, vignettes, sous-tuiles) en arrière-plan.""" """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") @app.get("/api/rebuild")
@ -1090,9 +1034,9 @@ def export_tiles(req: ExportRequest):
"""Assemble des dalles adjacentes en image ou PDF (export.py). """Assemble des dalles adjacentes en image ou PDF (export.py).
Local par nature : les tuiles assemblées sont celles du cache output/ 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 (la webapp légère les possède via le cache à la demande) — aucune
machine de traitement. Un seul export à la fois (CPU/mémoire limités sur délégation à la machine de traitement. Un seul export à la fois
Raspberry Pi). (CPU/mémoire limités sur Raspberry Pi).
""" """
import re as _re import re as _re
from .export import EXPORT_FORMATS, build_export from .export import EXPORT_FORMATS, build_export
@ -1153,4 +1097,13 @@ def export_file(name: str):
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn 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://<hôte>: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)

53
make-tls-cert.sh Executable file
View File

@ -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://<hôte>: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://<hôte>:PORT."

21
run.sh
View File

@ -25,7 +25,7 @@
# --serve-webapp [PORT] # --serve-webapp [PORT]
# Conteneur webapp seul (image légère) : carte servie depuis # Conteneur webapp seul (image légère) : carte servie depuis
# le cache local de tuiles, génération déléguée à la machine # 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 # -h Afficher l'aide complète
set -e set -e
@ -186,7 +186,7 @@ while [ $# -gt 0 ]; do
echo " --serve-webapp [PORT]" echo " --serve-webapp [PORT]"
echo " Conteneur webapp seul (image légère, sans PDAL/GPU) :" echo " Conteneur webapp seul (image légère, sans PDAL/GPU) :"
echo " carte servie depuis le cache local de tuiles, génération" 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 " (cf. docs/DEPLOY_WEBAPP.md)"
echo " --test Exécuter les tests unitaires" echo " --test Exécuter les tests unitaires"
echo " -h Afficher cette aide" echo " -h Afficher cette aide"
@ -299,38 +299,29 @@ fi
# Mode --serve-webapp : conteneur webapp SEUL (image légère, sans PDAL/GPU). # 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 # 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 # génération est déléguée à la machine de traitement et les tuiles sont
# par LIDAR_SYNC_CMD (rsync), rafraîchies aussi par LIDAR_AUTO_SYNC_SECONDS. # servies à la demande depuis LIDAR_GENERATION_URL (plus de rsync).
# Variables passées telles quelles depuis l'environnement hôte : # Variables passées telles quelles depuis l'environnement hôte :
# LIDAR_GENERATION_URL machine de traitement (ex: http://192.168.1.50: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_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 # LIDAR_REGEN_CIDR réseau autorisé à lancer les générations
# (défaut 192.168.1.0/24, vide = restriction levée) # (défaut 192.168.1.0/24, vide = restriction levée)
if [ "$WEBAPP_FLAG" -eq 1 ]; then if [ "$WEBAPP_FLAG" -eq 1 ]; then
build_webapp_image build_webapp_image
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
AUTO_SYNC_INFO="${LIDAR_AUTO_SYNC_SECONDS:-0}"
echo "============================================" echo "============================================"
echo " Carte LiDAR — webapp seule (image légère)" echo " Carte LiDAR — webapp seule (image légère)"
echo "============================================" echo "============================================"
echo " http://127.0.0.1:${WEBAPP_SERVE_PORT}/" echo " http://127.0.0.1:${WEBAPP_SERVE_PORT}/"
echo " Génération distante : ${LIDAR_GENERATION_URL:-non configurée (LIDAR_GENERATION_URL)}" echo " Génération distante : ${LIDAR_GENERATION_URL:-non configurée (LIDAR_GENERATION_URL)}"
if [ -n "$LIDAR_SYNC_CMD" ]; then echo " Cache local tuiles : à la demande (LIDAR_GENERATION_URL)"
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 "============================================" echo "============================================"
WEBAPP_ENV=() WEBAPP_ENV=()
[ -n "$LIDAR_GENERATION_URL" ] && WEBAPP_ENV+=(-e LIDAR_GENERATION_URL="$LIDAR_GENERATION_URL") [ -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_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_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") [ -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=() WEBAPP_SSH=()
[ -d "${HOME}/.ssh" ] && WEBAPP_SSH+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro") [ -d "${HOME}/.ssh" ] && WEBAPP_SSH+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro")
exec docker run --rm --init \ exec docker run --rm --init \

View File

@ -7,17 +7,21 @@
# WEBAPP_PORT port hôte (défaut 8973) # WEBAPP_PORT port hôte (défaut 8973)
# LIDAR_GENERATION_URL machine de traitement (ex. http://192.168.1.50: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_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 # LIDAR_REGEN_CIDR réseau autorisé à lancer les générations
# (défaut 192.168.1.0/24, vide = restriction levée) # (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://<hôte>:port)
# et le bouton ⌖ « centrer sur la position GPS » fonctionne sur téléphone
# (accepter l'avertissement « certificat non fiable » une fois).
#
# Usage : # Usage :
# ./serve-webapp.sh [start] démarrer (arrière-plan, redémarrage auto) # ./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 stop arrêter et supprimer le conteneur
# ./serve-webapp.sh restart redémarrer (relit webapp.env) # ./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 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 logs suivre les logs du conteneur
# ./serve-webapp.sh update git pull, rebuild de l'image, redémarrer # ./serve-webapp.sh update git pull, rebuild de l'image, redémarrer
set -e set -e
@ -25,8 +29,6 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
IMAGE="lidar-webapp" IMAGE="lidar-webapp"
CONTAINER="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 # Configuration locale (non versionnée) : webapp.env surcharge l'environnement
if [ -f "${SCRIPT_DIR}/webapp.env" ]; then if [ -f "${SCRIPT_DIR}/webapp.env" ]; then
@ -35,6 +37,22 @@ if [ -f "${SCRIPT_DIR}/webapp.env" ]; then
. "${SCRIPT_DIR}/webapp.env" . "${SCRIPT_DIR}/webapp.env"
set +a set +a
fi 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://<hôte>: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}" CMD="${1:-start}"
@ -53,7 +71,7 @@ lan_ip() {
cmd_start() { cmd_start() {
mkdir -p "${SCRIPT_DIR}/output" 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 "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 echo "Arrêter l'existant, ou relancer avec un autre port : WEBAPP_PORT=9000 $0" >&2
exit 1 exit 1
@ -68,17 +86,19 @@ cmd_start() {
if [ -n "${LIDAR_REMOTE_TOKEN:-}" ]; then if [ -n "${LIDAR_REMOTE_TOKEN:-}" ]; then
ENV_ARGS+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN") ENV_ARGS+=(-e LIDAR_REMOTE_TOKEN="$LIDAR_REMOTE_TOKEN")
fi 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 # Définie même vide (= restriction levée) ; absente = défaut webapp.py
if [ -n "${LIDAR_REGEN_CIDR+x}" ]; then if [ -n "${LIDAR_REGEN_CIDR+x}" ]; then
ENV_ARGS+=(-e LIDAR_REGEN_CIDR="${LIDAR_REGEN_CIDR:-}") ENV_ARGS+=(-e LIDAR_REGEN_CIDR="${LIDAR_REGEN_CIDR:-}")
fi 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=() SSH_ARGS=()
if [ -d "${HOME}/.ssh" ]; then if [ -d "${HOME}/.ssh" ]; then
SSH_ARGS+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro") SSH_ARGS+=(-v "${HOME}/.ssh:/home/lidar/.ssh:ro")
@ -90,6 +110,7 @@ cmd_start() {
--user 1000:1000 \ --user 1000:1000 \
-p "${WEBAPP_PORT}:8973" \ -p "${WEBAPP_PORT}:8973" \
-v "${SCRIPT_DIR}/output:/data/output" \ -v "${SCRIPT_DIR}/output:/data/output" \
"${TLS_ARGS[@]}" \
"${SSH_ARGS[@]}" \ "${SSH_ARGS[@]}" \
-e LIDAR_OUTPUT_DIR=/data/output \ -e LIDAR_OUTPUT_DIR=/data/output \
"${ENV_ARGS[@]}" \ "${ENV_ARGS[@]}" \
@ -97,7 +118,7 @@ cmd_start() {
echo -n "Attente du serveur sur le port ${WEBAPP_PORT}..." echo -n "Attente du serveur sur le port ${WEBAPP_PORT}..."
UP=0 UP=0
for _ in $(seq 1 30); do 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 UP=1
break break
fi fi
@ -113,18 +134,13 @@ cmd_start() {
echo "============================================" echo "============================================"
echo " URL : ${BASE_URL}/" echo " URL : ${BASE_URL}/"
if [ -n "$(lan_ip)" ]; then 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 fi
echo " Génération distante: ${LIDAR_GENERATION_URL:-non configurée}" echo " Génération distante: ${LIDAR_GENERATION_URL:-non configurée}"
if [ -n "${LIDAR_SYNC_CMD:-}" ]; then echo " Cache local tuiles : à la demande (LIDAR_GENERATION_URL)"
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 "============================================" echo "============================================"
} }
@ -139,7 +155,7 @@ cmd_status() {
else else
echo "Conteneur : ${CONTAINER} arrêté" echo "Conteneur : ${CONTAINER} arrêté"
fi 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 "" echo ""
else else
echo "Webapp : injoignable sur ${BASE_URL}" echo "Webapp : injoignable sur ${BASE_URL}"
@ -147,7 +163,7 @@ cmd_status() {
} }
cmd_sync() { 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 case "$CODE" in
200) echo "Sync lancée (suivi : ./serve-webapp.sh status, logs : ./serve-webapp.sh logs)" ;; 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)" ;; 409) echo "Une sync est déjà en cours (suivi : ./serve-webapp.sh status)" ;;

23
webapp-entrypoint.sh Executable file
View File

@ -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://<hôte>: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[@]}"}

View File

@ -12,16 +12,31 @@
# Jeton partagé si la machine de traitement définit LIDAR_API_TOKEN # Jeton partagé si la machine de traitement définit LIDAR_API_TOKEN
#LIDAR_REMOTE_TOKEN=un-secret-à-partager #LIDAR_REMOTE_TOKEN=un-secret-à-partager
# rsync qui ramène les tuiles traitées vers le cache local. # Les tuiles sont servies à la demande depuis LIDAR_GENERATION_URL (cache
# Les vignettes (index_thumbs/, index_subtiles/) sont régénérées localement, # local peuplé au fil des consultations) : plus de rsync ni de sync périodique.
# 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
# Réseaux autorisés à lancer les générations depuis la carte # Réseaux autorisés à lancer les générations depuis la carte
# (liste de CIDR séparés par virgules ; défaut : localhost + plages # (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 ; # 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) # vide pour lever la restriction)
#LIDAR_REGEN_CIDR=192.168.1.0/24 #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://<domaine> 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://<hôte>: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