From 1e63e4fe809d6e41be1301f25ec8ea1c5cc63ce2 Mon Sep 17 00:00:00 2001 From: k3nny Date: Sun, 27 Sep 2026 10:06:08 +0200 Subject: [PATCH] =?UTF-8?q?Biblioth=C3=A8que=20vid=C3=A9o=20partag=C3=A9e?= =?UTF-8?q?=20par=20URL=20sign=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrait de li.k3nny.fr et rendu indépendant du serveur : toute la configuration propre à l'hôte passe par li.env (URL publique, chemins, expiration, répertoires « HTML seulement », commande nginx). Écarts avec la version en production : - li-user del retire aussi la page .html, pas seulement le .xspf ; - li-gen ignore les répertoires cachés, pas seulement les fichiers ; - textes de la page HTML accentués. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 7 + LICENSE | 21 +++ README.md | 164 +++++++++++++++++ bin/li-gen | 362 ++++++++++++++++++++++++++++++++++++++ bin/li-user | 132 ++++++++++++++ compose.yaml | 39 ++++ li.env.example | 34 ++++ nginx/facade.conf.example | 62 +++++++ nginx/li.conf | 121 +++++++++++++ systemd/li-gen.service | 7 + systemd/li-gen.timer | 12 ++ tests/test_li_gen.py | 40 +++++ 12 files changed, 1001 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/li-gen create mode 100755 bin/li-user create mode 100644 compose.yaml create mode 100644 li.env.example create mode 100644 nginx/facade.conf.example create mode 100644 nginx/li.conf create mode 100644 systemd/li-gen.service create mode 100644 systemd/li-gen.timer create mode 100644 tests/test_li_gen.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e1bc80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Les secrets ne vivent jamais dans le depot. +users.json +users.conf +*.env +!li.env.example +playlists/ +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6772c05 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 k3nny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d3eb9b --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# li — une bibliothèque vidéo partagée par URL signées + +Partager une bibliothèque de films et de séries avec quelques proches, **lisible directement dans +VLC**, sans compte, sans portail d'authentification et sans secret partagé. + +Chaque personne reçoit **deux liens personnels** : + +| Document | Pour quoi faire | +|---|---| +| `.xspf` | Une playlist VLC contenant toute la bibliothèque (*Média → Ouvrir un flux réseau*) | +| `.html` | Une page autonome : l'arborescence, les tailles, un filtre par titre, un clic pour télécharger | + +Les deux portent **exactement les mêmes URL de téléchargement**, puisque la signature ne dépend que +du chemin et du secret de la personne, jamais du document qui la transporte. + +## Le principe : la révocation, c'est la table + +Chaque ayant droit possède un identifiant et un secret de 24 caractères, qui **ne quitte jamais le +serveur**. Une URL porte le nom, une expiration et un `md5(expiration + chemin + secret)`, que +nginx vérifie lui-même avec [`secure_link`](https://nginx.org/en/docs/http/ngx_http_secure_link_module.html) : + +``` +https://li.example.org/library/movies/Film.mkv?u=alice&e=2145916800&s=O8H1HbrY_SGVLnl8KP-AxA +``` + +| Levier | Rôle | +|---|---| +| `li-user del alice` | **La** révocation : tous les liens de la personne meurent dans la seconde | +| L'expiration `e=` | Un filet, rien de plus — fixée à 2038 par défaut | + +Si l'expiration est aussi lointaine, c'est parce que VLC indexe la reprise de lecture et +l'historique **par URL** : une signature renouvelée chaque nuit les effacerait chaque nuit. Ici, +les URL ne changent jamais, et la régénération nocturne ne fait qu'ajouter les nouveautés. + +Ce choix permet aussi de se passer d'un portail d'authentification (SSO, `auth_request`). VLC ne +suit pas une redirection vers une page de connexion, et une sous-requête d'authentification +répétée à chaque requête de plage (chaque avance rapide) coûte cher. Un jeton signé se vérifie sur +place, en quelques microsecondes. + +## Architecture + +```mermaid +flowchart LR + AMI(["Ami · VLC ou navigateur"]) + NGX["nginx · façade
443, TLS, X-Real-IP"] + subgraph NET["réseau Docker « li » · internal"] + LI["li — nginx dédié
vérifie la signature"] + end + MAP[("users.conf
map $arg_u → secret")] + LIB[("bibliothèque
lecture seule")] + + AMI -->|"https · URL signée"| NGX + NGX -->|"proxy_pass
proxy_buffering off"| LI + LI --> MAP + LI --> LIB +``` + +Le nginx de façade ne monte pas la bibliothèque : c'est le processus le plus exposé de la machine, +et lui donner accès à des centaines de gigaoctets élargirait son rayon d'explosion pour rien. Le +conteneur `li` ne publie aucun port, ne joint personne (réseau `internal`) et voit la bibliothèque +en lecture seule. + +## Contenu du dépôt + +| Fichier | Rôle | +|---|---| +| `bin/li-user` | Ajoute, révoque et liste les ayants droit, et affiche leurs liens (sh, `jq`, `openssl`) | +| `bin/li-gen` | Engendre la playlist et la page de chaque ayant droit (Python ≥ 3.8, sans dépendance) | +| `nginx/li.conf` | Vhost du conteneur `li` : signature, limites, en-têtes | +| `nginx/facade.conf.example` | Vhost de la façade TLS, à adapter | +| `compose.yaml` | Le conteneur `li` et son réseau | +| `systemd/li-gen.*` | Régénération nocturne | +| `li.env.example` | **Toute** la configuration propre au serveur | + +## Installation + +```bash +# 1. Configuration +sudo install -d /etc/li +sudo install -m 644 li.env.example /etc/li/li.env +sudoedit /etc/li/li.env # LI_BASE_URL, LI_LIBRARY, LI_DATA… + +# 2. Scripts et minuteur +sudo install -m 755 bin/li-gen bin/li-user /usr/local/sbin/ +sudo install -m 644 systemd/li-gen.service systemd/li-gen.timer /etc/systemd/system/ +sudo systemctl daemon-reload && sudo systemctl enable --now li-gen.timer + +# 3. Répertoires de données, avec une map vide pour que nginx démarre +. /etc/li/li.env +sudo install -d "$LI_DATA/conf" "$LI_DATA/playlists" +echo 'map $arg_u $li_secret { default ""; }' | sudo tee "$LI_DATA/conf/users.conf" + +# 4. Le conteneur +sudo docker compose --env-file /etc/li/li.env up -d +``` + +Côté façade, adaptez `nginx/facade.conf.example` (nom, certificat) et raccordez la façade au +réseau `li` (`networks: li: {external: true}` dans son `compose`). + +Hors Docker, `nginx/li.conf` s'utilise tel quel une fois ses chemins adaptés (`root`, `include`), +avec `LI_NGINX=nginx` dans `li.env`. + +## Utilisation + +```bash +li-user add alice # crée, engendre la map, recharge nginx, affiche les deux liens +li-user lien alice # retrouve les deux liens, sans rien changer +li-user list +li-user del alice # tue tous ses liens dans la seconde +li-gen # régénère à la main (le minuteur le fait chaque nuit) +``` + +Les liens **sont** les clés : transmettez-les par un canal privé (un envoi à usage unique d'un +gestionnaire de mots de passe convient), pas dans une discussion de groupe. + +### Ce qui va dans la playlist + +* Partout : les fichiers dont l'extension figure dans `LI_VIDEO_EXT`. +* Dans la page HTML seulement : **tous** les fichiers des répertoires de premier niveau listés dans + `LI_HTML_ONLY` (`software` par défaut), car un `.iso` n'a rien à faire dans une playlist VLC. + Chacun de ces répertoires doit avoir son `location` avec `Content-Disposition: attachment` dans + `nginx/li.conf`, pour qu'un `.html` ou un `.svg` déposé là soit téléchargé et non rendu. +* Les fichiers et répertoires cachés (`.Trash-1000`…) sont ignorés. + +### Limites côté lecteur + +| Limite | Pourquoi | +|---|---| +| Fichiers lus **tels quels**, sans transcodage | Un 4K HEVC ne passera pas sur un vieux téléphone | +| Les sous-titres intégrés au `.mkv` fonctionnent, les `.srt` voisins non | VLC ne cherche les sous-titres voisins que pour les fichiers locaux | +| Trois flux simultanés par personne | `limit_conn` par identifiant, et non par adresse | +| 12 Mo/s par flux, les 16 premiers Mo à pleine vitesse | Démarrage et avance rapide restent francs | + +## Pièges connus + +**Monter le répertoire, pas le fichier.** `li-user` publie la map par un `mv` atomique, qui +remplace l'inode. Un bind-mount *de fichier* épingle l'inode d'origine : le conteneur resterait sur +l'ancienne version, et un ayant droit ajouté n'existerait jamais pour nginx, sans la moindre +erreur. + +**Signer le chemin décodé.** `secure_link_md5` porte sur `$uri`, que nginx a déjà décodé. On signe +donc le chemin brut et on n'encode qu'au moment d'écrire l'URL. Dans l'autre ordre, on obtient un +`403` sur tout titre qui contient une espace ou un accent. De même, les `&` doivent devenir `&` +dans le XML, sans quoi VLC refuse le `.xspf`. + +**Ne pas journaliser la signature.** Le format `combined` enregistre la ligne de requête entière, +et donc le laissez-passer. Les deux nginx (façade et `li`) utilisent un format qui ne retient que +`$uri`. Seul le journal d'erreurs porte encore la requête complète, dans un cas étroit (signature +valide mais fichier disparu). + +**L'adresse du client.** Sans `realip`, le conteneur attribuerait toutes les requêtes à l'adresse +de la façade. Il fait confiance à `X-Real-IP` depuis les plages privées, et non depuis un +sous-réseau figé que Docker peut réattribuer. En contrepartie, la façade doit **écraser** cet +en-tête. + +## Tests + +```bash +python3 -m unittest discover tests +``` + +## Licence + +[MIT](LICENSE). diff --git a/bin/li-gen b/bin/li-gen new file mode 100755 index 0000000..f7284bf --- /dev/null +++ b/bin/li-gen @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Engendre, pour chaque ayant droit, sa playlist .xspf et sa page .html. + +Les deux portent EXACTEMENT les memes URL : la signature ne depend que du +chemin et du secret, jamais du document qui la transporte. + +Idempotent : relance-le autant que tu veux, il reecrit les memes URL. C est +voulu — une URL stable, c est VLC qui retrouve la reprise de lecture et +l historique, tous deux indexes par URL. + +Configuration : /etc/li/li.env (ou le fichier nomme par LI_CONF), que les +variables d environnement surchargent. Voir li.env.example. +""" +import base64 +import hashlib +import html +import json +import os +import shlex +import sys +import time +from pathlib import Path +from urllib.parse import quote +from xml.sax.saxutils import escape + +# Ces deux prefixes sont ceux du vhost nginx/li.conf : les changer ici impose +# de les changer la-bas. +PREFIXE = "/library" +PLAYLISTS = "/playlists" + +VIDEOS_DEFAUT = ".mkv .mp4 .m4v .avi .mov .webm .mpg .mpeg .ts" + + +# ----------------------------------------------------------- configuration + +def lis_env(chemin: Path) -> dict: + """Lit un fichier « CLE=valeur » compatible sh — celui que li-user source.""" + valeurs = {} + if not chemin.exists(): + return valeurs + for ligne in chemin.read_text(encoding="utf-8").splitlines(): + ligne = ligne.strip() + if not ligne or ligne.startswith("#") or "=" not in ligne: + continue + cle, valeur = ligne.split("=", 1) + mots = shlex.split(valeur, comments=True) + valeurs[cle.strip()] = " ".join(mots) + return valeurs + + +class Config: + def __init__(self, env: dict): + base = env.get("LI_BASE_URL", "").rstrip("/") + if not base: + raise SystemExit("LI_BASE_URL n est pas defini (voir li.env.example).") + self.base = base + self.racine = Path(env.get("LI_LIBRARY", "/srv/media/library")) + donnees = Path(env.get("LI_DATA", "/srv/appdata/li")) + self.ayants = donnees / "users.json" + self.sortie = donnees / "playlists" + # La revocation, c est la table des ayants droit — pas l expiration. + # Une expiration courte obligerait a resigner regulierement, donc a + # changer les URL, donc a faire perdre a VLC la reprise de lecture de + # chaque film. D ou 2038 par defaut. + self.expire = int(env.get("LI_EXPIRES", "2145916800")) + self.titre = env.get("LI_TITLE", "Bibliothèque") + self.videos = {e.lower() for e in env.get("LI_VIDEO_EXT", VIDEOS_DEFAUT).split()} + # Repertoires de premier niveau dont le contenu ne figure QUE dans la + # page HTML, jamais dans la playlist — et dont TOUS les fichiers sont + # pris, quelle que soit leur extension. + # + # Une playlist VLC n a rien a faire d un .iso ou d un .exe : elle ne + # saurait pas les lire, et les mettre dedans donnerait une liste ou un + # titre sur deux echoue a l ouverture. La page HTML, elle, ne fait que + # proposer des telechargements — tout y a sa place. + # + # Le vhost nginx/li.conf force « Content-Disposition: attachment » sur + # ces memes chemins. Les deux doivent rester d accord. + self.html_seulement = set(env.get("LI_HTML_ONLY", "software").split()) + + +def charge_config() -> Config: + env = lis_env(Path(os.environ.get("LI_CONF", "/etc/li/li.env"))) + env.update({k: v for k, v in os.environ.items() if k.startswith("LI_")}) + return Config(env) + + +# --------------------------------------------------------------- signature + +def signe(chemin: str, expire: int, secret: str) -> str: + """md5(expiration + chemin + secret), en base64url sans remplissage. + + C est exactement ce que calcule « secure_link_md5 » de nginx. Le secret + est en FIN de chaine : c est l ordre de la documentation nginx, et le seul + qui ne prete pas le flanc a une extension de longueur. + """ + brut = f"{expire}{chemin}{secret}".encode("utf-8") + return base64.urlsafe_b64encode(hashlib.md5(brut).digest()).decode().rstrip("=") + + +def url(cfg: Config, chemin: str, nom: str, secret: str) -> str: + # On signe le chemin DECODE — c est ce que nginx met dans $uri — et on + # n encode qu ici, pour l ecriture. Inverser les deux donne un 403 sur + # tout titre contenant une espace ou un accent. + return (f"{cfg.base}{quote(chemin)}" + f"?u={quote(nom)}&e={cfg.expire}&s={signe(chemin, cfg.expire, secret)}") + + +def url_media(cfg: Config, fichier: Path, nom: str, secret: str) -> str: + return url(cfg, f"{PREFIXE}/{fichier.relative_to(cfg.racine).as_posix()}", nom, secret) + + +# ------------------------------------------------------------------ outils + +def taille_lisible(octets: int) -> str: + for unite, seuil in (("To", 1 << 40), ("Go", 1 << 30), ("Mo", 1 << 20)): + if octets >= seuil: + return f"{octets / seuil:.1f} {unite}".replace(".", ",") + return f"{octets // 1024} ko" + + +def html_seulement(cfg: Config, chemin: Path) -> bool: + parties = chemin.relative_to(cfg.racine).parts + return len(parties) > 1 and parties[0] in cfg.html_seulement + + +def collecte(cfg: Config): + """(medias, autres) — les premiers vont partout, les seconds en HTML seul.""" + medias, autres = [], [] + for f in cfg.racine.rglob("*"): + if not f.is_file(): + continue + # Fichiers ET repertoires caches : .@__thumb, .Trash-1000 et consorts. + if any(p.startswith(".") for p in f.relative_to(cfg.racine).parts): + continue + if html_seulement(cfg, f): + autres.append(f) + elif f.suffix.lower() in cfg.videos: + medias.append(f) + ordre = lambda p: p.relative_to(cfg.racine).as_posix().lower() + return sorted(medias, key=ordre), sorted(autres, key=ordre) + + +def arbre(cfg: Config, fichiers): + """Reconstruit l arborescence : {dossier: sous-arbre}, fichiers a plat.""" + racine = {"dossiers": {}, "fichiers": []} + for f in fichiers: + parties = f.relative_to(cfg.racine).parts + noeud = racine + for dossier in parties[:-1]: + noeud = noeud["dossiers"].setdefault( + dossier, {"dossiers": {}, "fichiers": []}) + noeud["fichiers"].append(f) + return racine + + +def compte(noeud): + """(nombre de fichiers, octets) d un noeud et de toute sa descendance.""" + n = len(noeud["fichiers"]) + o = sum(f.stat().st_size for f in noeud["fichiers"]) + for sous in noeud["dossiers"].values(): + sn, so = compte(sous) + n += sn + o += so + return n, o + + +# -------------------------------------------------------------------- XSPF + +def xspf(cfg: Config, fichiers, nom, secret, horodatage): + pistes = [] + for f in fichiers: + rel = f.relative_to(cfg.racine) + pistes.append( + " \n" + f" {escape(url_media(cfg, f, nom, secret))}\n" + f" {escape(rel.stem)}\n" + f" {escape(rel.parent.as_posix().replace('/', ' · '))}\n" + " ") + return ('\n' + '\n' + f' {escape(nom)} — {len(fichiers)} titres ({horodatage})\n' + ' \n' + "\n".join(pistes) + '\n \n' + '\n') + + +# -------------------------------------------------------------------- HTML + +STYLE = """ +:root{--fond:#fbfaf8;--carte:#fff;--texte:#1b1b1b;--doux:#6b6b6b; +--trait:#e4e0d9;--accent:#2d6a4f;--survol:#f2efe9} +@media(prefers-color-scheme:dark){:root{--fond:#16181a;--carte:#1e2124; +--texte:#e8e6e3;--doux:#9a9793;--trait:#2e3236;--accent:#6ba583;--survol:#25292d}} +*{box-sizing:border-box} +body{margin:0;padding:0 16px 64px;background:var(--fond);color:var(--texte); +font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} +.enveloppe{max-width:920px;margin:0 auto} +header{padding:28px 0 16px;border-bottom:1px solid var(--trait);margin-bottom:18px} +h1{margin:0 0 6px;font-size:1.45rem;font-weight:650;letter-spacing:-.01em} +.sous{color:var(--doux);font-size:.875rem} +.actions{margin-top:14px;display:flex;gap:10px;flex-wrap:wrap} +.bouton{display:inline-block;padding:7px 13px;border:1px solid var(--trait); +border-radius:7px;background:var(--carte);color:var(--texte);text-decoration:none; +font-size:.85rem} +.bouton:hover{background:var(--survol)} +.bouton.fort{background:var(--accent);border-color:var(--accent);color:#fff} +#filtre{width:100%;margin:16px 0 20px;padding:10px 13px;border:1px solid var(--trait); +border-radius:8px;background:var(--carte);color:var(--texte);font:inherit;font-size:.9rem} +#filtre:focus{outline:2px solid var(--accent);outline-offset:-1px} +details{margin:0} +summary{cursor:pointer;padding:7px 8px;border-radius:6px;list-style:none; +display:flex;align-items:baseline;gap:9px;font-weight:550} +summary:hover{background:var(--survol)} +summary::-webkit-details-marker{display:none} +summary::before{content:"▸";color:var(--doux);font-size:.8em;flex:none; +transition:transform .12s;display:inline-block} +details[open]>summary::before{transform:rotate(90deg)} +.meta{color:var(--doux);font-weight:400;font-size:.8rem;margin-left:auto; +flex:none;white-space:nowrap} +.niveau{margin-left:15px;padding-left:9px;border-left:1px solid var(--trait)} +a.f{display:flex;align-items:baseline;gap:9px;padding:6px 8px;border-radius:6px; +color:var(--texte);text-decoration:none;font-size:.9rem} +a.f:hover{background:var(--survol)} +a.f::before{content:"↓";color:var(--accent);font-size:.85em;flex:none} +a.f .t{color:var(--doux);font-size:.8rem;margin-left:auto;flex:none; +white-space:nowrap;font-variant-numeric:tabular-nums} +.vide{display:none!important} +footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--trait); +color:var(--doux);font-size:.8rem} +""" + +SCRIPT = """ +const f=document.getElementById('filtre'); +f.addEventListener('input',()=>{ + const q=f.value.trim().toLowerCase(); + document.querySelectorAll('a.f').forEach(a=>{ + a.classList.toggle('vide', q!=='' && !a.dataset.n.includes(q)); + }); + document.querySelectorAll('details').forEach(d=>{ + const vu=d.querySelector('a.f:not(.vide)')!==null; + d.classList.toggle('vide', q!=='' && !vu); + if(q!=='') d.open=true; + }); +}); +""" + + +def rendre(cfg: Config, noeud, nom, secret, profondeur=0): + morceaux = [] + for dossier in sorted(noeud["dossiers"], key=str.lower): + sous = noeud["dossiers"][dossier] + n, o = compte(sous) + ouvert = " open" if profondeur == 0 else "" + morceaux.append( + f'{html.escape(dossier)}' + f'{n} · {taille_lisible(o)}' + f'
{rendre(cfg, sous, nom, secret, profondeur + 1)}
' + '') + for f in sorted(noeud["fichiers"], key=lambda p: p.name.lower()): + # Pour un film, l extension n apprend rien — le nom de release dit + # deja tout. Pour un fichier « HTML seulement », elle est au contraire + # l information principale : un .iso ne se telecharge pas comme un + # .exe. D ou le nom entier dans un cas, le radical dans l autre. + affiche = f.name if html_seulement(cfg, f) else f.stem + morceaux.append( + f'' + f'{html.escape(affiche)}' + f'{taille_lisible(f.stat().st_size)}') + return "".join(morceaux) + + +def page(cfg: Config, racine, medias, autres, nom, secret, horodatage): + fichiers = medias + autres + total = sum(f.stat().st_size for f in fichiers) + decompte = f"{len(medias)} titres" + if autres: + decompte += f" et {len(autres)} fichiers" + lien_xspf = url(cfg, f"{PLAYLISTS}/{nom}.xspf", nom, secret) + titre = html.escape(cfg.titre) + return f""" + + + + + + + +{titre} — {html.escape(nom)} + + + +
+
+

{titre}

+
Accès personnel de {html.escape(nom)} · + {decompte} · {taille_lisible(total)} · mise à jour le {horodatage}
+ +
+ +{rendre(cfg, racine, nom, secret)} +
+ Chaque lien de cette page t’identifie et n’est valable que pour toi. + Ne la transmets pas — demande plutôt qu’on ajoute la personne. +
+
+ + + +""" + + +# -------------------------------------------------------------------- corps + +def publie(chemin: Path, contenu: str): + tmp = chemin.with_name("." + chemin.name + ".tmp") + tmp.write_text(contenu, encoding="utf-8") + os.chmod(tmp, 0o644) + # Publication atomique : personne ne lit jamais un document a moitie + # ecrit, meme si la generation tombe en plein milieu. + os.replace(tmp, chemin) + + +def main() -> int: + cfg = charge_config() + if not cfg.ayants.exists(): + print(f"{cfg.ayants} absent — rien a engendrer.", file=sys.stderr) + return 1 + if not cfg.racine.is_dir(): + print(f"{cfg.racine} n est pas un repertoire.", file=sys.stderr) + return 1 + + ayants = json.loads(cfg.ayants.read_text(encoding="utf-8")) + medias, autres = collecte(cfg) + # L arbre de la page porte les deux ; la playlist ne verra que « medias ». + racine = arbre(cfg, medias + autres) + cfg.sortie.mkdir(parents=True, exist_ok=True) + horodatage = time.strftime("%d/%m/%Y à %Hh%M") + + for nom, secret in sorted(ayants.items()): + publie(cfg.sortie / f"{nom}.xspf", xspf(cfg, medias, nom, secret, horodatage)) + publie(cfg.sortie / f"{nom}.html", + page(cfg, racine, medias, autres, nom, secret, horodatage)) + print(f"{nom} : {len(medias)} pistes, {len(autres)} fichiers hors playlist") + + # Les documents des personnes revoquees ne doivent pas survivre au retrait + # de leur secret : sans signature valide ils seraient refuses, mais un + # fichier qui traine est un fichier qu on oublie. + for orphelin in list(cfg.sortie.glob("*.xspf")) + list(cfg.sortie.glob("*.html")): + if orphelin.stem not in ayants: + orphelin.unlink() + print(f"{orphelin.name} : retire (plus d ayant droit)") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/li-user b/bin/li-user new file mode 100755 index 0000000..0c553dd --- /dev/null +++ b/bin/li-user @@ -0,0 +1,132 @@ +#!/bin/sh +# Gestion des ayants droit de la bibliotheque. +# +# users.json est la SEULE source de verite ; la map nginx en est deduite et +# se reecrit entierement a chaque geste. Retirer quelqu un de ce fichier et +# recharger, c est tuer tous ses liens dans la seconde : c est la revocation. +# L expiration inscrite dans les URL n est qu un filet. +# +# Configuration : /etc/li/li.env (ou le fichier nomme par LI_CONF), partage +# avec li-gen. Voir li.env.example. +set -eu + +LI_CONF=${LI_CONF:-/etc/li/li.env} +# shellcheck disable=SC1090 +[ -f "$LI_CONF" ] && . "$LI_CONF" + +: "${LI_BASE_URL:?LI_BASE_URL n est pas defini (voir li.env.example)}" +LI_DATA=${LI_DATA:-/srv/appdata/li} +LI_EXPIRES=${LI_EXPIRES:-2145916800} # 2038 — doit valoir celui de li-gen +LI_NGINX=${LI_NGINX:-docker exec li nginx} +# li-gen lit les memes variables : on les lui transmet telles que chargees. +export LI_CONF LI_BASE_URL LI_DATA LI_EXPIRES + +BASE=${LI_BASE_URL%/} +AYANTS=$LI_DATA/users.json +MAP=$LI_DATA/conf/users.conf +LI_GEN=${LI_GEN:-$(dirname "$0")/li-gen} + +usage() { + echo "usage: li-user add|del|lien|list [nom]" >&2 + exit 2 +} + +verifie_nom() { + # Le nom voyage dans l URL et devient un nom de fichier : on le tient + # court et sans surprise plutot que d avoir a l echapper partout. + case "$1" in + *[!A-Za-z0-9_-]*|"") echo "Nom invalide : lettres, chiffres, - et _ seulement." >&2; exit 1 ;; + esac +} + +rendre_map() { + mkdir -p "$(dirname "$MAP")" + umask 077 + { + echo "# Engendre par li-user — ne pas editer a la main." + echo "# Un identifiant absent d ici obtient un secret VIDE, et le vhost" + echo "# refuse avant meme de calculer la moindre signature." + echo 'map $arg_u $li_secret {' + echo ' default "";' + jq -r 'to_entries | sort_by(.key)[] | " \"\(.key)\" \"\(.value)\";"' "$AYANTS" + echo '}' + } > "$MAP.tmp" + mv "$MAP.tmp" "$MAP" + chmod 644 "$MAP" # lu par le conteneur, qui le monte en lecture seule + + # shellcheck disable=SC2086 # LI_NGINX est une commande, mots compris + $LI_NGINX -t >/dev/null 2>&1 || { + echo "La configuration nginx ne passe plus le controle — rien recharge." >&2 + $LI_NGINX -t + exit 1 + } + # shellcheck disable=SC2086 + $LI_NGINX -s reload +} + +signe_uri() { # $1 = uri, $2 = secret + printf '%s' "$LI_EXPIRES$1$2" \ + | openssl md5 -binary | openssl base64 | tr '+/' '-_' | tr -d '=' +} + +lien() { + secret=$(jq -er --arg n "$1" '.[$n]' "$AYANTS") || { + echo "$1 : inconnu." >&2; exit 1 + } + # Les deux documents portent les MEMES liens de telechargement : la + # signature ne depend que du chemin et du secret, jamais du document + # qui la transporte. + for ext in html xspf; do + uri="/playlists/$1.$ext" + printf ' %-5s %s\n' "$ext" "$BASE$uri?u=$1&e=$LI_EXPIRES&s=$(signe_uri "$uri" "$secret")" + done +} + +[ -f "$AYANTS" ] || { mkdir -p "$LI_DATA"; umask 077; echo '{}' > "$AYANTS"; } + +case "${1:-}" in + add) + [ $# -eq 2 ] || usage + verifie_nom "$2" + if jq -e --arg n "$2" 'has($n)' "$AYANTS" >/dev/null; then + echo "$2 existe deja — « li-user lien $2 » pour retrouver son URL." >&2 + exit 1 + fi + secret=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24) + tmp=$(mktemp) + jq --arg n "$2" --arg s "$secret" '.[$n] = $s' "$AYANTS" > "$tmp" + mv "$tmp" "$AYANTS" + chmod 600 "$AYANTS" + rendre_map + "$LI_GEN" + echo + echo "Liens a transmettre a $2 :" + lien "$2" + ;; + del) + [ $# -eq 2 ] || usage + jq -e --arg n "$2" 'has($n)' "$AYANTS" >/dev/null || { + echo "$2 : inconnu." >&2; exit 1 + } + tmp=$(mktemp) + jq --arg n "$2" 'del(.[$n])' "$AYANTS" > "$tmp" + mv "$tmp" "$AYANTS" + chmod 600 "$AYANTS" + # Les documents partent AVANT le rechargement : un worker en cours de + # vidange a encore l ancienne map, et servirait un 404 la ou la + # reponse juste est 403. + rm -f "$LI_DATA/playlists/$2.xspf" "$LI_DATA/playlists/$2.html" + rendre_map + echo "$2 revoque — tous ses liens sont morts." + ;; + lien) + [ $# -eq 2 ] || usage + lien "$2" + ;; + list) + jq -r 'to_entries | sort_by(.key)[] | .key' "$AYANTS" + ;; + *) + usage + ;; +esac diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9924096 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,39 @@ +# Bibliotheque en lecture seule pour des tiers — un nginx DEDIE, qui ne fait +# que servir des fichiers signes. +# +# docker compose --env-file /etc/li/li.env up -d +# +# POURQUOI UN SECOND NGINX. Le proxy de facade est le processus le plus expose +# de la machine ; lui monter toute la bibliotheque elargirait son rayon +# d explosion pour rien. Ici, le conteneur qui tient les fichiers ne publie +# aucun port et ne joint personne : il vit sur un reseau « internal » ou la +# facade est son seul voisin. +services: + li: + image: nginx:1.29-alpine + container_name: li + restart: unless-stopped + volumes: + - ./nginx/li.conf:/etc/nginx/conf.d/default.conf:ro + # La table des ayants droit contient leurs secrets : elle vit hors du + # depot, dans LI_DATA. + # + # C est le REPERTOIRE qui est monte, et non le fichier. Un bind-mount de + # fichier epingle l INODE : li-user publie sa map par un « mv » atomique, + # qui remplace l inode, et le conteneur resterait indefiniment sur + # l ancienne version — un ayant droit ajoute n existerait jamais pour + # nginx, sans la moindre erreur. + - ${LI_DATA:?}/conf:/etc/nginx/li:ro + - ${LI_DATA:?}/playlists:/srv/li/playlists:ro + # La bibliotheque, en LECTURE SEULE. Le conteneur ne peut rien ecrire + # dans la bibliotheque, meme si nginx tombait. + - ${LI_LIBRARY:?}:/srv/li/library:ro + networks: [li] + security_opt: [no-new-privileges:true] + +networks: + # La facade rejoint ce reseau (« external: true » de son cote). Aucune + # sortie : « internal » interdit au conteneur de joindre quoi que ce soit. + li: + name: li + internal: true diff --git a/li.env.example b/li.env.example new file mode 100644 index 0000000..fa29de8 --- /dev/null +++ b/li.env.example @@ -0,0 +1,34 @@ +# Configuration de li — a copier en /etc/li/li.env. +# +# Lue par li-user (source par sh), par li-gen, et par docker compose +# (--env-file). D ou la syntaxe la plus simple : CLE=valeur, guillemets +# doubles si la valeur contient une espace. + +# Adresse publique, sans barre finale. Obligatoire. +LI_BASE_URL=https://li.example.org + +# La bibliotheque a partager (montee en lecture seule dans le conteneur). +LI_LIBRARY=/srv/media/library + +# users.json (les secrets), conf/users.conf (la map nginx), playlists/. +LI_DATA=/srv/appdata/li + +# Expiration inscrite dans les URL, en secondes depuis l epoque. 2038 : les +# URL ne bougent jamais, et VLC garde sa reprise de lecture. La revocation +# passe par « li-user del », pas par l expiration. +LI_EXPIRES=2145916800 + +# Repertoires de premier niveau publies EN ENTIER, mais seulement dans la page +# HTML (jamais dans la playlist). Separes par des espaces. Chacun doit avoir +# son « location » avec Content-Disposition dans nginx/li.conf. +LI_HTML_ONLY=software + +# Extensions retenues ailleurs — dans la playlist et dans la page. +LI_VIDEO_EXT=".mkv .mp4 .m4v .avi .mov .webm .mpg .mpeg .ts" + +# Titre de la page HTML. +LI_TITLE="Bibliothèque" + +# Commande nginx utilisee par li-user pour controler et recharger (« -t », +# « -s reload » lui sont ajoutes). « nginx » tout court hors Docker. +LI_NGINX="docker exec li nginx" diff --git a/nginx/facade.conf.example b/nginx/facade.conf.example new file mode 100644 index 0000000..3cfdf90 --- /dev/null +++ b/nginx/facade.conf.example @@ -0,0 +1,62 @@ +# Vhost de la FACADE — le nginx en frontal, qui porte le TLS et relaie vers le +# conteneur « li ». A adapter : nom, chemins du certificat, et le resolveur +# si la facade ne tourne pas dans Docker. +# +# Aucune authentification ici : le laissez-passer voyage dans l URL, et il est +# verifie par le conteneur « li », seul a connaitre les secrets. Un portail +# d authentification (auth_request, SSO…) serait de toute facon inutilisable : +# VLC ne suit pas une redirection vers une page de connexion. + +# Le format « combined » par defaut journalise $request, donc la ligne de +# requete ENTIERE, donc la signature. Ce vhost a donc son propre format : la +# facade ne doit pas consigner ce que le conteneur « li » prend soin de taire. +# Un journal qui porte le laissez-passer est un journal rejouable. +log_format li_edge '$remote_addr "$arg_u" "$uri" $status $body_bytes_sent $request_time'; + +# Resolveur interne de Docker : indispensable des qu un proxy_pass porte une +# variable. Sans lui, nginx resout l amont UNE SEULE FOIS au demarrage, et un +# conteneur qui redemarre avec une autre adresse produit un 502 permanent. +# A retirer si ce resolveur est deja declare ailleurs dans le contexte http. +resolver 127.0.0.11 valid=30s ipv6=off; + +server { + listen 80; + server_name li.example.org; + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl; + http2 on; + server_name li.example.org; + + ssl_certificate /etc/letsencrypt/live/li.example.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/li.example.org/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_tickets off; + + access_log /dev/stdout li_edge; + + add_header Strict-Transport-Security "max-age=31536000" always; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + # ECRASE, jamais complete : c est ce qui rend X-Real-IP digne de la + # confiance que lui accorde le conteneur « li ». + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Sans « proxy_buffering off », nginx accumulerait le .mkv avant d en + # relayer le premier octet. La lecture ne demarrerait jamais. + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 600s; + send_timeout 600s; + + set $upstream http://li:80; + proxy_pass $upstream; + } +} diff --git a/nginx/li.conf b/nginx/li.conf new file mode 100644 index 0000000..d9b5b56 --- /dev/null +++ b/nginx/li.conf @@ -0,0 +1,121 @@ +# Vhost du conteneur « li » — un nginx DEDIE, qui ne fait que verifier des +# signatures et servir des fichiers. Il est derriere une facade (voir +# facade.conf.example) qui porte le TLS. +# +# Chemins vus DANS le conteneur (voir compose.yaml) : +# /etc/nginx/li/users.conf la map des ayants droit, engendree par li-user +# /srv/li/library/ la bibliotheque, en lecture seule +# /srv/li/playlists/ les .xspf et .html engendres par li-gen + +# La table des ayants droit : « map $arg_u $li_secret ». Engendree par +# li-user, jamais editee a la main. +# +# Son « default "" » est le coeur du dispositif : un identifiant inconnu ou +# revoque n obtient pas un secret par defaut devinable, il n obtient RIEN, et +# la requete est refusee avant meme le calcul de signature. +include /etc/nginx/li/users.conf; + +# L ADRESSE REELLE DU CLIENT, et non celle du proxy de facade. +# +# Sans ceci, $remote_addr vaut l adresse de la facade sur le reseau Docker : +# toutes les lectures, tous les 403 et toute la limitation de debit seraient +# imputes a un seul et meme voisin. +# +# La confiance porte sur les plages privees et non sur le sous-reseau du +# jour : il est ATTRIBUE par Docker, et un reseau recree peut en changer. Une +# plage figee ferait alors silencieusement reapparaitre l adresse du proxy +# dans le journal — exactement le genre de panne qui ne se voit pas. Le risque +# est nul tant que le reseau est « internal » et que la facade y est le seul +# voisin de ce conteneur. +# +# X-Real-IP n est pas falsifiable : la facade l ECRASE a chaque requete avec +# le $remote_addr qu elle constate, et c est elle qui est en frontal. +set_real_ip_from 10.0.0.0/8; +set_real_ip_from 172.16.0.0/12; +set_real_ip_from 192.168.0.0/16; +real_ip_header X-Real-IP; + +# Une zone par ayant droit, et non par adresse : trois flux simultanes depuis +# trois pays, c est du partage de lien, et cela se voit. +limit_conn_zone $arg_u zone=li_ayants:1m; + +# Le journal d ACCES ne contient jamais $request_uri. La signature EST le +# laissez-passer : un journal qui la porte est un journal rejouable, et il est +# lu par plus de monde que la table des secrets. $uri est le chemin decode, +# sans les arguments. nginx echappe lui-meme les caracteres speciaux de $arg_u. +# +# Le journal d ERREUR, lui, porte la ligne de requete entiere — donc la +# signature — et nginx ne sait pas l en priver. Le cas est etroit : une +# signature VALIDE dont le fichier a disparu. Il sort sur stderr, donc dans +# les journaux Docker, lisibles du seul root de l hote. +log_format li '$remote_addr "$arg_u" "$uri" $status $body_bytes_sent $request_time'; + +server { + listen 80; + server_name _; + + root /srv/li; + autoindex off; + access_log /dev/stdout li; + + # « hash,expiration » lus dans les arguments de l URL. La signature porte + # sur $uri, c est-a-dire le chemin DECODE : le generateur signe donc le + # chemin brut et n encode qu au moment d ecrire l URL. Une signature + # calculee sur la forme encodee donne un 403 sur tout titre accentue. + secure_link $arg_s,$arg_e; + secure_link_md5 "$secure_link_expires$uri$li_secret"; + + # Ces trois controles sont au niveau server et non dans un location : la + # phase de reecriture s execute AVANT le choix du location, ils couvrent + # donc les deux arborescences sans etre ecrits deux fois. « if » suivi de + # « return » est la seule forme que la documentation nginx ne deconseille + # pas. + if ($li_secret = "") { return 403; } # inconnu, ou revoque + if ($secure_link = "") { return 403; } # signature fausse ou absente + if ($secure_link = "0") { return 410; } # lien perime + + limit_conn li_ayants 3; + limit_rate_after 16m; # les premiers 16 Mo a pleine vitesse : demarrage + limit_rate 12m; # et avance rapide restent francs + sendfile_max_chunk 512k; + + add_header Cache-Control "private, no-store" always; + add_header X-Content-Type-Options "nosniff" always; + + location /library/ { } + + # Le sous-arbre « software » se TELECHARGE, il ne s affiche pas. + # + # On y depose du .txt, du .html, du .svg — que le navigateur rendrait + # volontiers DANS la page, sur l origine de la bibliotheque. Ce serveur ne + # pose aucun cookie et n a pas de session a voler. Mais un depot de + # fichiers qui execute ce qu on y met est une surprise qu on s epargne + # pour trois lignes. + # + # Ce chemin doit rester d accord avec LI_HTML_ONLY (li.env) : un + # repertoire de plus la-bas, c est un location de plus ici. + location /library/software/ { + # « add_header » dans un location REMPLACE ceux du bloc parent au lieu + # de s y ajouter. Les deux en-tetes du niveau server sont donc repris + # ici : sans cela, ce sous-arbre perdrait son « no-store ». + add_header Cache-Control "private, no-store" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Disposition "attachment" always; + } + + # Ce repertoire contient DEUX documents par ayant droit : sa playlist + # .xspf et sa page .html. Ils portent exactement les memes URL + # signees — la signature ne depend que du chemin et du secret, jamais du + # document qui la transporte. + # + # Et ils se typent tout seuls, chacun par une voie differente : + # .html — connu de mime.types, donc servi en text/html sans rien faire ; + # .xspf — inconnu de mime.types, donc rattrape par le default_type. + # + # D ou « default_type » et NON un bloc « types » : « types » REMPLACE la + # table heritee de mime.types au lieu de s y ajouter. Un bloc « types » ici + # ferait perdre au .html son type — et aux .mkv le leur. + location /playlists/ { default_type application/xspf+xml; } + + location / { return 404; } +} diff --git a/systemd/li-gen.service b/systemd/li-gen.service new file mode 100644 index 0000000..c9bfc13 --- /dev/null +++ b/systemd/li-gen.service @@ -0,0 +1,7 @@ +[Unit] +Description=Regeneration des playlists de la bibliotheque li +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/li-gen diff --git a/systemd/li-gen.timer b/systemd/li-gen.timer new file mode 100644 index 0000000..0a1faea --- /dev/null +++ b/systemd/li-gen.timer @@ -0,0 +1,12 @@ +# Les nouveautes apparaissent dans les playlists sans intervention. Les URL, +# elles, ne bougent pas : la signature ne depend que du chemin et du secret. +[Unit] +Description=Regeneration nocturne des playlists de la bibliotheque li + +[Timer] +OnCalendar=*-*-* 05:20:00 +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/tests/test_li_gen.py b/tests/test_li_gen.py new file mode 100644 index 0000000..44c5a3a --- /dev/null +++ b/tests/test_li_gen.py @@ -0,0 +1,40 @@ +"""python3 -m unittest discover tests""" +import importlib.machinery +import importlib.util +import subprocess +import unittest +from pathlib import Path + +_chargeur = importlib.machinery.SourceFileLoader( + "li_gen", str(Path(__file__).resolve().parent.parent / "bin" / "li-gen")) +_spec = importlib.util.spec_from_loader("li_gen", _chargeur) +li_gen = importlib.util.module_from_spec(_spec) +_chargeur.exec_module(li_gen) + + +class Signature(unittest.TestCase): + def test_identique_a_openssl(self): + # Ce que calcule li-user, donc ce que verifie secure_link_md5. + chemin, secret = "/library/movies/Été à Paris/The Janitor’s Boy.mkv", "abc123" + brut = f"2145916800{chemin}{secret}" + attendu = subprocess.run( + ["sh", "-c", "printf '%s' \"$1\" | openssl md5 -binary | openssl base64 " + "| tr '+/' '-_' | tr -d '='", "sh", brut], + check=True, capture_output=True, text=True).stdout.strip() + self.assertEqual(li_gen.signe(chemin, 2145916800, secret), attendu) + + def test_signe_le_chemin_decode(self): + cfg = li_gen.Config({"LI_BASE_URL": "https://li.example.org/"}) + u = li_gen.url(cfg, "/library/a b’c.mkv", "alice", "s3cret") + self.assertTrue(u.startswith("https://li.example.org/library/a%20b%E2%80%99c.mkv?")) + self.assertTrue(u.endswith("&s=" + li_gen.signe("/library/a b’c.mkv", 2145916800, "s3cret"))) + + +class Configuration(unittest.TestCase): + def test_base_obligatoire(self): + with self.assertRaises(SystemExit): + li_gen.Config({}) + + +if __name__ == "__main__": + unittest.main()