Bibliothèque vidéo partagée par URL signées

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 10:06:08 +02:00
co-authored by Claude Opus 5.5
commit 1e63e4fe80
12 changed files with 1001 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Les secrets ne vivent jamais dans le depot.
users.json
users.conf
*.env
!li.env.example
playlists/
__pycache__/
+21
View File
@@ -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.
+164
View File
@@ -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 |
|---|---|
| `<nom>.xspf` | Une playlist VLC contenant toute la bibliothèque (*Média → Ouvrir un flux réseau*) |
| `<nom>.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["<b>nginx</b> · façade<br/><i>443, TLS, X-Real-IP</i>"]
subgraph NET["réseau Docker « li » · internal"]
LI["<b>li</b> — nginx dédié<br/><i>vérifie la signature</i>"]
end
MAP[("users.conf<br/><i>map $arg_u → secret</i>")]
LIB[("bibliothèque<br/><b>lecture seule</b>")]
AMI -->|"https · URL signée"| NGX
NGX -->|"proxy_pass<br/>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 `&amp;`
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).
Executable
+362
View File
@@ -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(
" <track>\n"
f" <location>{escape(url_media(cfg, f, nom, secret))}</location>\n"
f" <title>{escape(rel.stem)}</title>\n"
f" <album>{escape(rel.parent.as_posix().replace('/', ' · '))}</album>\n"
" </track>")
return ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<playlist version="1" xmlns="http://xspf.org/ns/0/">\n'
f' <title>{escape(nom)} — {len(fichiers)} titres ({horodatage})</title>\n'
' <trackList>\n' + "\n".join(pistes) + '\n </trackList>\n'
'</playlist>\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'<details{ouvert}><summary>{html.escape(dossier)}'
f'<span class="meta">{n} · {taille_lisible(o)}</span></summary>'
f'<div class="niveau">{rendre(cfg, sous, nom, secret, profondeur + 1)}</div>'
'</details>')
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'<a class="f" href="{html.escape(url_media(cfg, f, nom, secret), quote=True)}" '
f'download="{html.escape(f.name, quote=True)}" '
f'data-n="{html.escape(affiche.lower(), quote=True)}">'
f'<span>{html.escape(affiche)}</span>'
f'<span class="t">{taille_lisible(f.stat().st_size)}</span></a>')
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"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Aucune ressource externe, et aucun referent : les URL de cette page sont
des laissez-passer, elles n ont a fuir vers aucun tiers. -->
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex,nofollow,noarchive">
<title>{titre} — {html.escape(nom)}</title>
<style>{STYLE}</style>
</head>
<body>
<div class="enveloppe">
<header>
<h1>{titre}</h1>
<div class="sous">Accès personnel de <b>{html.escape(nom)}</b> ·
{decompte} · {taille_lisible(total)} · mise à jour le {horodatage}</div>
<div class="actions">
<a class="bouton fort" href="{html.escape(lien_xspf, quote=True)}">Ouvrir dans VLC</a>
</div>
</header>
<input id="filtre" type="search" placeholder="Filtrer par titre…" autocomplete="off">
{rendre(cfg, racine, nom, secret)}
<footer>
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.
</footer>
</div>
<script>{SCRIPT}</script>
</body>
</html>
"""
# -------------------------------------------------------------------- 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())
Executable
+132
View File
@@ -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
+39
View File
@@ -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
+34
View File
@@ -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"
+62
View File
@@ -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;
}
}
+121
View File
@@ -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
# <nom>.xspf et sa page <nom>.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; }
}
+7
View File
@@ -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
+12
View File
@@ -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
+40
View File
@@ -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()