diff --git a/Dockerfile b/Dockerfile index 5e2cdcb..58e2fb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,10 @@ RUN pip install --no-cache-dir . && rtg verifier # Le conteneur voit les disques du Mac sous /Volumes, aux mêmes chemins que # le Mac : les sources notées dans les voyage.toml restent valables. ENV XDG_CONFIG_HOME=/config TZ=Europe/Paris PYTHONUNBUFFERED=1 \ - RTG_RACINE=/Volumes/CLEROADTRIPS/Road-Trips RTG_PREFIXE=/admin + RTG_RACINE=/Volumes/CLEROADTRIPS/Road-Trips RTG_PREFIXE=/admin \ + RTG_ARRET_INACTIF=120 EXPOSE 8765 # Aucun port publié : seul le nginx du site (même réseau Docker) le joint. CMD exec rtg --racine "$RTG_RACINE" interface --proxy --prefixe "$RTG_PREFIXE" \ - --ecoute 0.0.0.0 --sans-navigateur + --ecoute 0.0.0.0 --sans-navigateur --arret-inactif "$RTG_ARRET_INACTIF" diff --git a/README.md b/README.md index 44a1bc3..3b128a4 100644 --- a/README.md +++ b/README.md @@ -183,14 +183,16 @@ Pour administrer le site depuis son adresse publique (bouton « Connexion » en ### L'administration dans Docker -Plutôt que de laisser `rtg interface` ouvert dans un Terminal, on peut le faire tourner dans un conteneur, à côté du nginx du site : il démarre avec Docker et redémarre tout seul. Le `Dockerfile` est à la racine de RoadTripsGenerator (Python, ffmpeg, exiftool). +Plutôt que de laisser `rtg interface` ouvert dans un Terminal, on peut le faire tourner dans un conteneur, à côté du nginx du site. Le `Dockerfile` est à la racine de RoadTripsGenerator (Python, ffmpeg, exiftool). 1. Définir le mot de passe sur le Mac, une fois : `rtg mot-de-passe`. Le conteneur lit le même fichier. 2. Ajouter le service `rtg` au `docker-compose.yml` du site : voir `exemples/docker-compose.yml`. Le conteneur voit `/Volumes` aux mêmes chemins que le Mac : la clé, le disque des rushs… Les sources notées dans les `voyage.toml` restent donc valables. 3. Dans `nginx.conf`, faire pointer `/admin/` vers ce conteneur : voir `exemples/nginx-admin.conf`. Le site reste en ligne même si le conteneur RTG est arrêté. -4. `docker compose up -d --build`. +4. `docker compose build rtg`, puis `docker compose up -d` (le site ; l'administration ne démarre pas avec lui). -Après une mise à jour de RTG : `docker compose up -d --build rtg`. Un disque branché après le démarrage du conteneur peut demander `docker compose restart rtg`. L'encodage utilise les processeurs attribués à Docker (réglages de Docker Desktop, « Resources ») ; l'encodeur matériel VideoToolbox du Mac n'est pas disponible dans le conteneur. +L'administration est **à la demande** : `docker compose up -d rtg` l'ouvre, `docker compose stop rtg` la ferme, et elle se ferme d'elle-même après 2 heures sans activité (variable `RTG_ARRET_INACTIF`, en minutes). Fermée, `/admin/` affiche « Administration fermée » et le site reste en ligne. + +Après une mise à jour de RTG : `docker compose build rtg`. Un disque branché après le démarrage du conteneur peut demander `docker compose restart rtg`. L'encodage utilise les processeurs attribués à Docker (réglages de Docker Desktop, « Resources ») ; l'encodeur matériel VideoToolbox du Mac n'est pas disponible dans le conteneur. Le bouton n'est qu'un lien : la protection est celle de l'interface (mot de passe, blocage après plusieurs essais faux, cookie de session limité à `/admin/`, envoyé seulement en HTTPS). diff --git a/exemples/docker-compose.yml b/exemples/docker-compose.yml index 5155bc7..f169585 100644 --- a/exemples/docker-compose.yml +++ b/exemples/docker-compose.yml @@ -1,5 +1,5 @@ # docker-compose.yml du site, avec l'administration RTG dans son propre conteneur. -# Après une mise à jour de RTG : docker compose up -d --build rtg +# Après une mise à jour de RTG : docker compose build rtg services: roadtrip: image: nginx:alpine @@ -15,9 +15,13 @@ services: - traefik.http.routers.ouaibe_roadtrip.rule=Host(`roadtrip.ouaibe.fr`) || Host(`roadtrips.ouaibe.fr`) - traefik.http.routers.ouaibe_roadtrip.entrypoints=websecure + # Administration à la demande : elle ne démarre pas avec le reste. + # ouvrir : docker compose up -d rtg fermer : docker compose stop rtg + # Elle se ferme d'elle-même après 2 h sans activité (RTG_ARRET_INACTIF, en minutes). rtg: build: /Volumes/CLEROADTRIPS/RoadTripsGenerator - restart: unless-stopped + profiles: ["admin"] + restart: "no" networks: - ouaibe_network volumes: diff --git a/exemples/nginx-admin.conf b/exemples/nginx-admin.conf index 32adcdb..7e13b4e 100644 --- a/exemples/nginx-admin.conf +++ b/exemples/nginx-admin.conf @@ -18,6 +18,14 @@ proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_buffering off; proxy_read_timeout 300s; + # Conteneur RTG arrêté : une page claire plutôt qu'une erreur 502. + error_page 502 504 = @admin_fermee; + } + + location @admin_fermee { + default_type "text/html; charset=utf-8"; + add_header Cache-Control no-store always; + return 503 "Administration fermée

Administration fermée

Elle n'est ouverte que pendant les mises à jour du site.

Retour au site

"; } # Réglages de RTG : inutile de les publier. diff --git a/rtg/cli.py b/rtg/cli.py index a4ad0b3..e6ea0cc 100644 --- a/rtg/cli.py +++ b/rtg/cli.py @@ -58,6 +58,8 @@ def main(argv=None): help="derrière le serveur web du site (mot de passe obligatoire)") i.add_argument("--prefixe", default="", help="chemin de l'interface sur le site, par exemple /admin") i.add_argument("--ecoute", help="adresse d'écoute (défaut 127.0.0.1 ; 0.0.0.0 si le serveur web est ailleurs)") + i.add_argument("--arret-inactif", type=float, default=0, metavar="MINUTES", + help="s'arrêter après MINUTES sans activité (0 = jamais)") mp = sp.add_parser("mot-de-passe", help="définir ou supprimer le mot de passe de l'interface") mp.add_argument("--supprimer", action="store_true") @@ -119,7 +121,7 @@ def main(argv=None): sys.exit("❌ --certificat et --cle vont ensemble.") lancer(racine, args.port, ouvrir=not (args.sans_navigateur or args.proxy), distant=args.distant, certificat=args.certificat, cle=args.cle, proxy=args.proxy, prefixe=args.prefixe, - ecoute=args.ecoute) + ecoute=args.ecoute, arret_inactif=args.arret_inactif) except KeyboardInterrupt: print("\nInterrompu.") sys.exit(130) diff --git a/rtg/interface/serveur.py b/rtg/interface/serveur.py index 0e3648b..4fe66d8 100644 --- a/rtg/interface/serveur.py +++ b/rtg/interface/serveur.py @@ -26,6 +26,7 @@ import signal import ssl import string import threading +import time import webbrowser from contextlib import redirect_stdout from datetime import date, timedelta @@ -107,6 +108,7 @@ class Application: self.prefixe = ("/" + prefixe.strip("/")) if prefixe.strip("/") else "" ENV.globals["base"] = self.prefixe self._cache = {} # voyage → (empreinte des sources, analyse) + self.derniere_activite = time.time() self.routes = [] for methode, motif, fn in [ ("GET", r"/", self.accueil), @@ -674,6 +676,10 @@ def creer_gestionnaire(app, port, gardien=None): return self.client_address[0] def traiter(self, methode, tete=False): + # Activité, pour l'arrêt automatique : les relèves périodiques d'un + # onglet resté ouvert ne comptent pas. + if not self.path.split("?")[0].endswith(("/tache/bandeau", "/tache/fragment")): + app.derniere_activite = time.time() try: if not (gardien.distant or gardien.proxy) and self.headers.get("Host") not in hotes: raise Erreur(403, "Hôte refusé") @@ -861,14 +867,15 @@ def _echapper(s): def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=None, - proxy=False, prefixe="", ecoute=None): + proxy=False, prefixe="", ecoute=None, arret_inactif=0): """Lance l'interface. distant : écoute sur toutes les interfaces réseau, mot de passe obligatoire ; certificat, cle : HTTPS ; proxy : derrière le serveur web du site (qui transmet https://…/admin/ à 127.0.0.1:port), mot de passe obligatoire ; prefixe : « /admin » ; ecoute : adresse d'écoute imposée (« 0.0.0.0 » si le serveur web est sur - une autre machine).""" + une autre machine) ; arret_inactif : minutes sans activité (ni traitement en + cours) au bout desquelles l'interface s'arrête d'elle-même, 0 = jamais.""" hors_machine = ecoute and ecoute not in ("127.0.0.1", "localhost", "::1") if (distant or proxy or hors_machine) and not acces.defini(): raise SystemExit("❌ L'accès depuis le réseau demande un mot de passe : lancez d'abord « rtg mot-de-passe ».") @@ -915,6 +922,21 @@ def lancer(racine, port=8765, ouvrir=True, distant=False, certificat=None, cle=N def sur_sigterm(*_): raise KeyboardInterrupt signal.signal(signal.SIGTERM, sur_sigterm) + + if arret_inactif: + print(f" Arrêt automatique après {arret_inactif:g} min sans activité.") + + def surveiller(): + while True: + time.sleep(min(30, arret_inactif * 30)) + t = taches.COURANTE + if t and t.en_cours: + app.derniere_activite = time.time() + elif time.time() - app.derniere_activite > arret_inactif * 60: + print(f"💤 Aucune activité depuis {arret_inactif:g} min : arrêt de l'interface.") + serveur.shutdown() + return + threading.Thread(target=surveiller, daemon=True, name="rtg-veille").start() try: serveur.serve_forever() except KeyboardInterrupt: