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:
@@ -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 `&`
|
||||
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).
|
||||
Reference in New Issue
Block a user