Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 d4fa4488cc Façade Rust, lot 2 : interface, authentification et /api/ping
L'interface (index, marked, service worker, manifeste, polices) est
désormais servie par loki-rs, embarquée par build.rs depuis la même source
que le go:embed — les deux binaires d'un build servent la même UI. Mêmes
en-têtes de cache que le Go, gzip à la première demande puis gardé en
mémoire, jamais sur les polices ni en HEAD.

L'authentification de pilotage est portée à l'identique (requireWebAuth,
crossSiteReject, localHostName, Bearer en temps constant, mêmes messages,
mêmes codes) ; les cas de TestCrossSiteReject sont repris un pour un. La
clé est demandée à l'API interne du Go à chaque requête ; si elle ne
répond pas proprement, la façade ferme (503). /api/ping est la première
route authentifiée servie par Rust.

Le jeton de l'API interne est tiré au hasard à chaque démarrage et passé
au Go par l'environnement. La façade rend 404 sur /internal/* venu de
l'extérieur et efface X-Loki-Internal entrant avant tout relais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:40:10 +02:00
MichaelandClaude Opus 5.5 a99d988e70 API interne pour la façade Rust : clé de pilotage à la demande
Lot 2 de la migration : la façade va authentifier elle-même ses routes
/api/*. La clé de pilotage vit dans la base bbolt, verrouillée par ce
process ; la façade la demande donc à GET /internal/v1/auth, à chaque
requête — un changement de clé prend effet tout de suite, comme avant.

La réponse contient un secret, d'où trois verrous : route branchée
uniquement sur l'écoute TCP directe (le tunnel du relais sert le mux nu),
jeton tiré au hasard par la façade à chaque démarrage et exigé en
temps constant, 404 sans rien révéler sinon. Base illisible : 503, la
façade fermera l'accès plutôt que de l'ouvrir.

Spec du lot 2 jointe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:35:00 +02:00
MichaelandClaude Opus 5.5 4b859c4eb6 Conteneur : façade Rust en frontal, binaires mis à jour au démarrage
L'image embarque loki-rs (étape Alpine, binaire musl statique) et
l'entrypoint en fait la porte d'entrée : `loki-rs serve` sur le port
public, le Go derrière lui sur 127.0.0.1:8091. LOKI_FACADE=0 revient à
l'ancien schéma si la façade pose problème.

Au démarrage, le loki-rs de l'image installe la dernière release
« rolling » dans /data/bin/<build>/ ; l'entrypoint prend ensuite loki et
loki-rs du même dossier (which-bin), et un lien dans /usr/local/sbin fait
que `docker exec loki loki …` vise lui aussi la version installée. C'est
toujours le loki-rs de l'image qui met à jour : un binaire publié
défectueux ne peut pas bloquer le correctif suivant.

CI : job Rust (fmt, clippy -D warnings, tests). Publication : la release
« rolling » part à chaque push sur main avec le même numéro de run que
l'image (LOKI_BUILD) ; IMAGE_ABI, source unique, empêche d'installer des
binaires qui exigent une image plus récente. manifest.json est envoyé en
dernier.

Message corrigé quand l'image est plus récente que la release : rien à
tirer dans ce cas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:33:18 +02:00
MichaelandClaude Opus 5.5 0a6914c936 Façade Rust loki-rs : proxy devant le Go et mise à jour au démarrage
Premier lot de la migration vers Rust. Le binaire loki-rs :

- serve : prend le port public (8090), lance `loki web 8091` en écoute
  locale et lui relaie tout — HTTP, flux SSE trame par trame, WebSocket.
  Les en-têtes de bout en bout passent intacts (Host, Authorization,
  Origin, Sec-Fetch-*, X-Forwarded-Proto : le Go en tire ses contrôles
  d'accès) ; X-Loki-Via entrant est effacé, un client ne doit jamais
  pouvoir se faire passer pour le tunnel. Go mort : relancé avec attente
  croissante, 502 lisible entre-temps.
- self-update : lit la release « rolling », installe loki et loki-rs dans
  $LOKI_HOME/bin/<build>/ après vérification SHA-256, bascule par
  réécriture atomique de release.json. Les versions s'ordonnent par numéro
  de run CI ; une ABI d'image différente n'est jamais installée. Toute
  erreur laisse l'état précédent, le démarrage n'est jamais bloqué.
  Le HTTPS passe par curl (présent dans l'image) : pas de pile TLS ici.
- which-bin : dossier d'où l'entrypoint prend les deux binaires, toujours
  ensemble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:30:09 +02:00
MichaelandClaude Opus 5.5 342f787eff Web : adresse d'écoute réglable par LOKI_WEB_BIND
La façade Rust du conteneur prend le port public et lance `loki web`
derrière elle : le Go doit alors n'écouter qu'en local, sinon il resterait
joignable en direct sans passer par la façade. Défaut inchangé (0.0.0.0).

Plan du lot 1 de la migration Rust joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:31:28 +02:00
MichaelandClaude Opus 5.5 f1f73cfc50 Spec : migration Rust, lot 1 — façade et mise à jour au démarrage
La réécriture en Rust se fait par lots, Rust en production dès le premier :
une façade loki-rs prend le port 8090 et proxifie vers le Go, qui passe en
écoute locale sur 8091. Chaque lot suivant lui retire des routes.

Le même lot fait que le conteneur prend la dernière version au démarrage :
release GitHub « rolling » publiée à chaque push sur main, binaires vérifiés
par SHA256SUMS et posés dans /data/bin, garde-fou d'ABI d'image, jamais de
boot bloqué.

La façade efface X-Loki-Via à l'entrée : sur l'écoute directe, l'en-tête
pouvait être forgé pour contourner le contrôle d'hôte anti-rebinding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:27:59 +02:00
LogiFlow 6ea661d705 Merge branch 'claude/bold-faraday-11i7lh' : Strata sur un SSD dédié (Unraid)
Strata sur un SSD dédié (Unraid)
2026-10-08 12:30:44 +02:00
Claude e236069c92 Strata sur un SSD dédié : place libre mesurée sur /data/strata, caches pip/HF hors de l'image
- strataDetect mesure l'espace libre de /data/strata (et non /data) : un SSD
  monté à cet endroit est celui qui compte pour l'installation.
- L'installeur reçoit XDG_CACHE_HOME=/data/strata/cache : les caches pip et
  Hugging Face ne remplissent plus /root, c.-à-d. l'image Docker d'Unraid.
- docker-compose.unraid.yml + README : monter un pool SSD en /data/strata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLbFcZj6mJjDpWvCpcChGA
2026-10-08 09:33:26 +00:00
LogiFlow 6afe07a823 Merge branch 'claude/wizardly-lamport-jfgrq5' : MTP sans recharger le modèle en brouillon
MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
2026-10-08 11:18:00 +02:00
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
45 changed files with 4274 additions and 28 deletions

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ dist/
!README.md
.env
.env.*
rust/target/
+18
View File
@@ -31,6 +31,24 @@ jobs:
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
macos:
runs-on: macos-latest
steps:
+54
View File
@@ -54,6 +54,7 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
@@ -61,3 +62,56 @@ jobs:
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
+3
View File
@@ -22,3 +22,6 @@ Thumbs.db
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
+25
View File
@@ -42,6 +42,21 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev
WORKDIR /src
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
# deux binaires d'une image servent toujours la même interface.
COPY internal/loki/ui/ internal/loki/ui/
COPY rust/ rust/
RUN cd rust && cargo build --release --locked \
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
@@ -131,6 +146,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
@@ -147,6 +163,15 @@ ENV LOKI_CONTAINER=1 \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
+1
View File
@@ -0,0 +1 @@
1
+77 -2
View File
@@ -16,7 +16,9 @@ seule image.
```
┌────────────────── conteneur loki ──────────────────┐
│ loki web (UI + API, port 8090, premier plan) │
│ loki-rs serve (façade Rust, port 8090) │
│ │ relaie HTTP/SSE/WebSocket │
│ loki web (UI + API Go, 127.0.0.1:8091) │
│ │ pilote (fichier PID — pas de systemd) │
│ loki serve ──exec──► llama-server (CUDA, :8080) │
│ ▲ modèles .gguf │
@@ -35,6 +37,13 @@ seule image.
redémarre le moteur normalement.
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
seule l'UI (8090) l'est.
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML,
JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à
l'identique (la clé est demandée au Go par une API interne locale,
protégée par un jeton tiré à chaque démarrage). Plan et feuille de route :
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Démarrage rapide (Docker, GPU NVIDIA)
@@ -120,6 +129,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### Strata sur un SSD dédié (Unraid)
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
et un disque dur de l'array est à proscrire.
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
Le SSD est alors monté en `/mnt/strata`.
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
les presets restent valides : le chemin vu par le conteneur ne change pas),
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
montage et occupe de la place pour rien.
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
```yaml
- /mnt/strata/loki:/data/strata
```
(sous les deux volumes existants), puis *Compose Up*.
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
proposées, puis installe.
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
ou cache : le pool `strata` n'est lié à aucun partage.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -135,6 +181,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
`docker-compose.unraid.yml` → **Compose Up**.
4. Interface : `http://<ip-unraid>:8090`.
## Mise à jour au démarrage
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
changements de code.
- À chaque push sur `main`, la CI publie la release GitHub
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
dit ce qui s'est passé (`[loki-rs update] …`, puis
`[entrypoint] binaires Loki : …`).
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
les binaires déjà en place servent, le conteneur démarre normalement.
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
conteneurs plus anciens n'installent pas ces binaires et le journal demande
un `docker compose pull`.
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
téléchargé : une version publiée défectueuse ne peut pas empêcher le
correctif suivant d'arriver au redémarrage d'après.
- `docker exec loki loki …` utilise aussi la version installée.
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
## Configuration
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
@@ -152,6 +224,9 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
@@ -1025,7 +1100,7 @@ Une étape à la fois, mesurée avant la suivante :
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
+13 -2
View File
@@ -16,8 +16,10 @@
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
#
# Mise à jour : Compose Down puis Up avec « pull image », ou
# « docker compose pull » avant Up.
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
@@ -64,6 +66,15 @@ services:
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
+39 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
set -eu
: "${LOKI_HOME:=/data}"
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
@@ -59,4 +87,12 @@ else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
exec loki web "${LOKI_WEB_PORT:-8090}"
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
# si la façade pose problème.
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
if [ "${LOKI_FACADE:-1}" = "0" ]; then
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
fi
exec "$BIN_DIR/loki-rs" serve
@@ -0,0 +1,122 @@
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Global Constraints
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
## File Structure
```
IMAGE_ABI ABI d'image (entier)
rust/Cargo.toml workspace
rust/loki-rs/Cargo.toml
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
rust/loki-rs/src/child.rs supervision de `loki web`
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
rust/loki-rs/tests/proxy.rs intégration façade
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
internal/loki/web_server.go LOKI_WEB_BIND
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
.github/workflows/ci.yml job rust
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
README.md section mise à jour au démarrage
```
---
### Task 1: Go — adresse d'écoute configurable
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
**Interfaces (produits) :**
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
### Task 3: self-update (téléchargement vérifié, installation atomique)
**Files:** `src/update/mod.rs`, `tests/update.rs`
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 4: Façade proxy
**Files:** `src/proxy.rs`, `tests/proxy.rs`
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 5: Supervision du Go + commande `serve`
**Files:** `src/child.rs`, `src/main.rs`
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
- [ ] Commit.
### Task 6: Image, entrypoint, CI, release rolling, doc
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
- CI : job `rust` (fmt, clippy, test sur ubuntu).
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
## Self-review
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
@@ -0,0 +1,150 @@
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
Date : 2026-10-13
Statut : validé (design), à planifier
## Contexte
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
semaines : la migration est **progressive**, par lots, avec Rust en production
dès le premier lot.
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
sans `docker compose pull` manuel.
## Architecture de cohabitation (« strangler fig »)
```
:8090 ──► loki-rs (Rust, axum/tokio)
├─ routes portées → traitées en Rust
└─ tout le reste → proxy HTTP / SSE / WebSocket
→ loki web (Go) sur 127.0.0.1:8091
```
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
de `loki web`). Le port public reste 8090 : compose et reverse proxy
inchangés.
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
de socle aux lots suivants.
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
le ferme pour `loki web` hors conteneur. Test dédié.
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
### Démarrage et arrêt
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
arrêté proprement par Go, comme aujourd'hui).
## Mise à jour au démarrage
### Publication (CI)
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
release GitHub **`rolling`** (pré-release, assets écrasés) :
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
- `loki-rs-linux` — binaire Rust musl
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
### ABI d'image
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
source du manifeste dans le même commit) quand le code exige un changement de
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
installé : il ne pourrait pas tourner correctement.
### Au boot (`loki-rs self-update`)
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
étape 6.
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
Toute erreur → `.part` supprimés, rien remplacé.
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
binaires viennent toujours de la même source (jamais un Go de l'image avec un
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
l'ABI.
## Organisation du code Rust
```
rust/
Cargo.toml workspace
loki-rs/
src/main.rs CLI : serve | self-update | version
src/proxy.rs façade HTTP/SSE/WebSocket
src/child.rs supervision du processus Go
src/update/ manifeste, téléchargement, SHA256SUMS, installation
```
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
## Tests
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
installation atomique (échec au milieu → rien remplacé).
- Intégration : façade devant un faux backend axum —
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
entrant effacé, 502 si backend absent.
- Mise à jour : faux serveur de release local (URL de base injectable par
variable d'environnement de test).
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
## Feuille de route des lots suivants (hors périmètre de ce spec)
| Lot | Contenu |
|---|---|
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
| 3 | Supervision de llama-server |
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
| 5 | Outils de l'agent, MCP, mode Code/LSP |
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
| 7 | Mémoire, tâches, dictée, relais, CDP |
| 8 | Store bbolt → redb, retrait de Go |
Chaque lot aura son propre spec, plan et PR.
@@ -0,0 +1,74 @@
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
## But
Faire passer en Rust les premières routes réelles, et surtout poser le socle
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
## Périmètre
1. **UI statique servie par Rust**, embarquée dans le binaire à la
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
| Route | Content-Type | Cache-Control | Autre |
|---|---|---|---|
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
GET et HEAD seulement (les autres méthodes restent relayées au Go).
Compression gzip si le client l'accepte, sauf polices (comme
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
Version compressée calculée une fois, à la première demande.
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
nu et ne la voit jamais.
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
caractères, comparaison en temps constant). Jeton absent, trop court ou
faux → 404 (rien ne révèle l'existence de la route).
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
passe au Go par l'environnement.
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
- clé relue à **chaque requête** (API interne) : un changement de clé prend
effet immédiatement ;
- API interne en erreur (Go absent, 503, réponse illisible) → 503
`{"error":"configuration illisible — réessaie dans un instant"}` ;
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
(mêmes messages que le Go) ;
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
`WWW-Authenticate: Bearer realm="loki"` ;
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
le trafic du tunnel ne traverse pas la façade).
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
après authentification — première route `/api/*` servie par Rust.
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
en-tête `X-Loki-Internal` entrant effacé avant relais.
## Hors périmètre
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
## Tests
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
avec clé et version, autres chemins relayés au mux).
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
ping avec et sans clé.
+1
View File
@@ -527,6 +527,7 @@ type serveSysInfo struct {
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
+22
View File
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
return
}
si.Draft = p
if a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
+31
View File
@@ -1,6 +1,7 @@
package loki
import (
"os"
"reflect"
"slices"
"strings"
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
si: func(s *serveSysInfo) {
s.Draft = s.Model
s.DraftGGUF = s.GGUF
s.DraftIsModel = true
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
t.Errorf("variable : %q", n)
}
}
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
func TestProbeSpecDraftIsModel(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
dir := t.TempDir()
model := dir + "/Swift-27B.gguf"
other := dir + "/mtp-Swift.gguf"
for _, p := range []string{model, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
if !si.DraftIsModel {
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
}
si = serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
if si.DraftIsModel || si.Draft != other {
t.Errorf("tête à part prise pour le modèle : %+v", si)
}
}
+7 -2
View File
@@ -284,7 +284,9 @@ const strataMinArch = 75
func strataDetect() strataEnv {
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
if f := diskFree(LokiHome()); f > 0 {
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
if f := diskFree(strataHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
@@ -774,7 +776,10 @@ func strataRunInstall(req strataInstallReq) {
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
"--low-ram", "off",
}
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
// le reste de Strata dans /data/strata.
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
return
+15
View File
@@ -362,3 +362,18 @@ func TestStrataApplySettingsExperts(t *testing.T) {
t.Errorf("vide doit laisser le choix en place : %q", out)
}
}
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
}
}
+6
View File
@@ -1069,6 +1069,12 @@ func friendlyLLMError(err error) error {
}
func errEngineDown() error {
// Un refus de connexion ne veut pas toujours dire « il charge encore » : si
// le journal montre que le dernier lancement a échoué, c'est cela qu'il faut
// dire — réessayer dans quelques secondes n'y changera rien.
if msg := modelLoadError(); msg != "" {
return fmt.Errorf("⚠️ %s", msg)
}
return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
}
+166
View File
@@ -0,0 +1,166 @@
//go:build linux
package loki
import (
"os"
"strconv"
"strings"
"syscall"
"time"
)
// procRef : un processus repéré par son PID ET son instant de démarrage — un
// PID recyclé pendant l'attente ne passe pas pour le même processus.
type procRef struct {
pid int
start string
}
// procStat : les champs de /proc/<pid>/stat utiles ici.
type procStat struct {
state string
ppid, pgrp, sid int
start string
}
// readProcStat lit /proc/<pid>/stat. Le nom (champ 2, entre parenthèses) peut
// contenir des blancs et des parenthèses : on repart de la DERNIÈRE fermante.
func readProcStat(pid int) (procStat, bool) {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return procStat{}, false
}
return parseProcStat(string(b))
}
func parseProcStat(s string) (procStat, bool) {
i := strings.LastIndexByte(s, ')')
if i < 0 {
return procStat{}, false
}
// f[0] est le champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, f[19]
// l'instant de démarrage (champ 22).
f := strings.Fields(s[i+1:])
if len(f) < 20 {
return procStat{}, false
}
ppid, _ := strconv.Atoi(f[1])
pgrp, _ := strconv.Atoi(f[2])
sid, _ := strconv.Atoi(f[3])
return procStat{state: f[0], ppid: ppid, pgrp: pgrp, sid: sid, start: f[19]}, true
}
// procTable : tous les processus vivants (les zombies ont fini de tourner : leur
// mémoire, VRAM comprise, est déjà rendue).
func procTable() map[int]procStat {
ents, err := os.ReadDir("/proc")
if err != nil {
return nil
}
t := make(map[int]procStat, len(ents))
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
if st, ok := readProcStat(pid); ok && st.state != "Z" {
t[pid] = st
}
}
return t
}
// procTreeOf : le chef root et tout ce qui en descend — sa session et son
// groupe (Setsid au lancement du service), plus les descendants par la
// filiation, pour un enfant qui aurait pris sa propre session (le serveur
// Python de Strata lance son moteur dans un processus à part). À relever AVANT
// d'envoyer le moindre signal : une fois le chef mort, ses orphelins sont
// rattachés à PID 1 et la filiation est perdue.
func procTreeOf(t map[int]procStat, root, self int) []procRef {
in := map[int]bool{}
for pid, st := range t {
if pid != self && (pid == root || st.sid == root || st.pgrp == root) {
in[pid] = true
}
}
// Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute.
for grew := true; grew; {
grew = false
for pid, st := range t {
if !in[pid] && pid != self && in[st.ppid] {
in[pid] = true
grew = true
}
}
}
refs := make([]procRef, 0, len(in))
for pid := range in {
refs = append(refs, procRef{pid: pid, start: t[pid].start})
}
return refs
}
// procRefAlive : le processus tourne encore (ni disparu, ni zombie, ni
// remplacé par un autre sous le même PID).
func procRefAlive(r procRef) bool {
st, ok := readProcStat(r.pid)
return ok && st.state != "Z" && st.start == r.start
}
func procRefsAlive(refs []procRef) []procRef {
var alive []procRef
for _, r := range refs {
if procRefAlive(r) {
alive = append(alive, r)
}
}
return alive
}
// stopProcTree arrête le service lancé avec Setsid (pid = chef de session) et
// N'EN REVIENT QU'UNE FOIS TOUT L'ARBRE PARTI.
//
// Attendre le seul chef ne suffit pas : avec Strata, le chef est le serveur
// Python, et le moteur qui tient la VRAM (cache d'experts, plusieurs Go) est un
// autre processus qui finit de s'arrêter après lui. La relance qui suivait
// 500 ms plus tard démarrait alors llama-server sur une carte encore pleine —
// « cudaMalloc failed: out of memory » sur un modèle qui tient pourtant — et
// Strata mesurait la RAM disponible avec l'ancien moteur encore chargé.
//
// SIGTERM à tous, grace pour finir proprement, SIGKILL aux survivants, puis
// settle au plus pour que le noyau les ait vraiment retirés (un processus
// coincé dans une lecture disque ne meurt qu'à la sortie de l'appel système).
// Renvoie ceux qui tournent encore au-delà (rien à faire de plus, mais le dire).
func stopProcTree(pid int, grace, settle time.Duration) []procRef {
refs := procTreeOf(procTable(), pid, os.Getpid())
if len(refs) == 0 {
return nil
}
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for _, r := range refs {
_ = syscall.Kill(r.pid, syscall.SIGTERM)
}
if left := waitProcRefs(refs, grace); len(left) == 0 {
return nil
}
_ = syscall.Kill(-pid, syscall.SIGKILL)
for _, r := range procRefsAlive(refs) {
_ = syscall.Kill(r.pid, syscall.SIGKILL)
}
return waitProcRefs(refs, settle)
}
// waitProcRefs attend que tous aient disparu, au plus wait. Renvoie les survivants.
func waitProcRefs(refs []procRef, wait time.Duration) []procRef {
deadline := time.Now().Add(wait)
for {
left := procRefsAlive(refs)
if len(left) == 0 || time.Now().After(deadline) {
return left
}
time.Sleep(100 * time.Millisecond)
}
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package loki
import (
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestParseProcStat(t *testing.T) {
st, ok := parseProcStat("4242 (python3 (x) y) S 4200 4100 4000 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0")
if !ok || st.state != "S" || st.ppid != 4200 || st.pgrp != 4100 || st.sid != 4000 || st.start != "987654" {
t.Fatalf("lecture fausse : %+v ok=%v", st, ok)
}
if _, ok := parseProcStat("4242 (tronqué"); ok {
t.Error("ligne sans parenthèse fermante acceptée")
}
}
func TestProcTreeOf(t *testing.T) {
tab := map[int]procStat{
100: {ppid: 1, pgrp: 100, sid: 100, start: "a"}, // chef (Setsid)
101: {ppid: 100, pgrp: 100, sid: 100, start: "b"},
200: {ppid: 101, pgrp: 200, sid: 200, start: "c"}, // moteur dans sa propre session
201: {ppid: 200, pgrp: 200, sid: 200, start: "d"},
300: {ppid: 1, pgrp: 300, sid: 300, start: "e"}, // étranger
400: {ppid: 1, pgrp: 100, sid: 100, start: "f"}, // orphelin rattaché à PID 1
500: {ppid: 100, pgrp: 100, sid: 100, start: "g"}, // nous-mêmes
}
got := map[int]bool{}
for _, r := range procTreeOf(tab, 100, 500) {
got[r.pid] = true
}
for _, pid := range []int{100, 101, 200, 201, 400} {
if !got[pid] {
t.Errorf("PID %d oublié", pid)
}
}
if got[300] || got[500] {
t.Errorf("hors de l'arbre pris : %v", got)
}
}
// Le chef meurt vite, son enfant (dans une autre session, comme le moteur de
// Strata) traîne à s'arrêter : stopProcTree ne revient qu'une fois tout parti.
func TestStopProcTreeWaitsForChildren(t *testing.T) {
// Le chef lance un enfant qui ignore SIGTERM dans sa propre session, écrit
// son PID, puis attend.
pidFile := t.TempDir() + "/child"
script := `setsid sh -c 'trap "" TERM; echo $$ > ` + pidFile + `; while :; do sleep 0.1; done' & wait`
cmd := exec.Command("sh", "-c", script)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
t.Skip("sh indisponible :", err)
}
go cmd.Wait() // pas de zombie pour le chef
var child int
for i := 0; i < 50 && child == 0; i++ {
time.Sleep(50 * time.Millisecond)
if b, err := os.ReadFile(pidFile); err == nil {
child, _ = strconv.Atoi(strings.TrimSpace(string(b)))
}
}
if child == 0 {
t.Fatal("l'enfant n'a pas démarré")
}
st, ok := readProcStat(child)
if !ok || st.sid == cmd.Process.Pid {
t.Fatalf("l'enfant devait avoir sa propre session : %+v", st)
}
ref := procRef{pid: child, start: st.start}
if left := stopProcTree(cmd.Process.Pid, 300*time.Millisecond, 5*time.Second); len(left) != 0 {
t.Fatalf("survivants : %v", left)
}
if procRefAlive(ref) {
t.Fatal("l'enfant tourne encore au retour de stopProcTree")
}
}
+12 -12
View File
@@ -125,25 +125,25 @@ func userCheckStarted(pid int) error {
func userSvcStop(verbose bool) error {
pid := readServicePID()
if pid <= 0 || !processAlive(pid) {
// Le chef parti, ses enfants peuvent tourner encore (moteur de Strata) :
// on ne conclut « rien à arrêter » que si l'arbre entier est vide.
if pid <= 0 || len(procTreeOf(procTable(), pid, os.Getpid())) == 0 {
_ = os.Remove(pidFilePath())
if verbose {
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
}
return nil
}
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
// groupe entier, donc llama-server s'arrête avec lui.
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for i := 0; i < 40 && processAlive(pid); i++ {
time.Sleep(100 * time.Millisecond)
}
if processAlive(pid) {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
// trouver la VRAM et la RAM libres.
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
_ = os.Remove(pidFilePath())
if len(left) > 0 {
fmt.Fprintf(os.Stderr, "%s %d processus du moteur toujours là après SIGKILL (PID %d…) : "+
"la mémoire du GPU peut ne pas être encore rendue\n", yellow("[attention]"), len(left), left[0].pid)
}
if verbose {
fmt.Println(green("[ok]") + " arrêté")
}
+7 -2
View File
@@ -6635,7 +6635,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -6645,7 +6648,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+7 -2
View File
@@ -1383,7 +1383,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -1393,7 +1396,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+13 -1
View File
@@ -107,6 +107,13 @@ func modelLoadErrorFrom(log string) string {
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
start := 0
for i, l := range lines {
// Une pile d'appels cite les fonctions traversées
// (…server_context_impl10load_model…) : ce n'est pas une nouvelle
// tentative, et repartir de là perdait la cause écrite juste avant
// (« cudaMalloc failed: out of memory » devenait « a planté »).
if isBacktraceFrame(l) {
continue
}
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
// avant tout chargement : c'est lui, la dernière tentative.
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
@@ -159,7 +166,8 @@ func modelLoadErrorFrom(log string) string {
strings.Contains(low, "cudamalloc failed"),
strings.Contains(low, "failed to allocate"):
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, " +
"et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
if reason == "" {
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
@@ -196,6 +204,10 @@ func modelLoadErrorFrom(log string) string {
}
}
// isBacktraceFrame : une ligne de pile d'appels de glibc
// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »).
func isBacktraceFrame(l string) bool { return strings.Contains(l, ")[0x") }
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
+16
View File
@@ -0,0 +1,16 @@
package loki
import "testing"
// La façade Rust lance `loki web` derrière elle : le Go ne doit alors écouter
// qu'en local, sinon il resterait joignable en direct, sans la façade.
func TestWebBindAddr(t *testing.T) {
t.Setenv("LOKI_WEB_BIND", "")
if got := webBindAddr(8090); got != "0.0.0.0:8090" {
t.Fatalf("défaut : %q", got)
}
t.Setenv("LOKI_WEB_BIND", "127.0.0.1")
if got := webBindAddr(8091); got != "127.0.0.1:8091" {
t.Fatalf("local : %q", got)
}
}
+81
View File
@@ -0,0 +1,81 @@
package loki
import (
"crypto/subtle"
"net/http"
"os"
"strings"
)
// API interne entre la façade Rust (rust/loki-rs) et ce serveur.
//
// La façade reprend des routes une à une ; pour authentifier celles qu'elle
// sert elle-même, elle doit connaître la clé de pilotage, qui vit dans la base
// bbolt — verrouillée en exclusif par ce process. Elle la demande donc ici, à
// chaque requête (même règle que requireWebAuth : un changement de clé prend
// effet immédiatement).
//
// Trois verrous, parce que la réponse contient un secret :
// - la route n'existe que sur l'écoute TCP directe (withInternalAPI enveloppe
// le mux dans cmdWeb) : le tunnel du relais sert le mux nu et ne la voit
// jamais ;
// - elle exige le jeton que la façade tire au hasard à chaque démarrage et
// transmet par l'environnement (LOKI_INTERNAL_TOKEN) ;
// - sans jeton, jeton trop court ou faux : 404, rien ne trahit la route.
const (
internalTokenEnv = "LOKI_INTERNAL_TOKEN"
internalTokenHeader = "X-Loki-Internal"
internalPrefix = "/internal/"
// Un jeton plus court qu'un secret de 128 bits désactive l'API interne :
// mieux vaut une façade qui ferme l'accès qu'une route devinable.
internalTokenMinLen = 32
)
func internalToken() string {
t := strings.TrimSpace(os.Getenv(internalTokenEnv))
if len(t) < internalTokenMinLen {
return ""
}
return t
}
// withInternalAPI sert /internal/* et passe tout le reste à next.
func withInternalAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, internalPrefix) {
next.ServeHTTP(w, r)
return
}
tok := internalToken()
got := r.Header.Get(internalTokenHeader)
if tok == "" || subtle.ConstantTimeCompare([]byte(got), []byte(tok)) != 1 {
http.NotFound(w, r)
return
}
switch r.URL.Path {
case "/internal/v1/auth":
handleInternalAuth(w, r)
default:
http.NotFound(w, r)
}
})
}
// handleInternalAuth : de quoi authentifier une requête de pilotage côté Rust.
// Base illisible → 503 : la façade ferme alors l'accès, elle n'ouvre jamais
// sur une panne d'E/S (cf. readWebKeyErr).
func handleInternalAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
return
}
key, err := readWebKeyErr()
if err != nil {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "configuration illisible"})
return
}
w.Header().Set("Cache-Control", "no-store")
sendJSON(w, http.StatusOK, map[string]any{"web_key": key, "version": Version})
}
+75
View File
@@ -0,0 +1,75 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const jetonTest = "0123456789abcdef0123456789abcdef"
func appelInterne(t *testing.T, path, jeton string) *httptest.ResponseRecorder {
t.Helper()
suite := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("mux"))
})
r := httptest.NewRequest("GET", path, nil)
if jeton != "" {
r.Header.Set(internalTokenHeader, jeton)
}
w := httptest.NewRecorder()
withInternalAPI(suite).ServeHTTP(w, r)
return w
}
func TestAPIInterneFermeeSansJeton(t *testing.T) {
t.Setenv(internalTokenEnv, "")
if w := appelInterne(t, "/internal/v1/auth", jetonTest); w.Code != 404 {
t.Fatalf("sans jeton configuré : %d, attendu 404", w.Code)
}
// Jeton configuré mais trop court : API désactivée, même avec le bon.
t.Setenv(internalTokenEnv, "court")
if w := appelInterne(t, "/internal/v1/auth", "court"); w.Code != 404 {
t.Fatalf("jeton trop court : %d, attendu 404", w.Code)
}
}
func TestAPIInterneRefuseMauvaisJeton(t *testing.T) {
t.Setenv(internalTokenEnv, jetonTest)
for _, j := range []string{"", "x", strings.ToUpper(jetonTest)} {
if w := appelInterne(t, "/internal/v1/auth", j); w.Code != 404 {
t.Fatalf("jeton %q : %d, attendu 404", j, w.Code)
}
}
}
func TestAPIInterneAuth(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
if err := putStr(bkState, "web_key", "loki-web-secret"); err != nil {
t.Fatal(err)
}
w := appelInterne(t, "/internal/v1/auth", jetonTest)
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
var got map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["web_key"] != "loki-web-secret" || got["version"] != Version {
t.Fatalf("réponse : %+v", got)
}
if w := appelInterne(t, "/internal/v1/autre", jetonTest); w.Code != 404 {
t.Fatalf("route inconnue : %d", w.Code)
}
}
func TestAPIInterneLaisseLeResteAuMux(t *testing.T) {
t.Setenv(internalTokenEnv, jetonTest)
if w := appelInterne(t, "/api/ping", ""); w.Body.String() != "mux" {
t.Fatalf("relais au mux : %q", w.Body.String())
}
}
+15 -2
View File
@@ -33,7 +33,7 @@ func cmdWeb(args []string) error {
port = n
}
mux := newWebMux()
addr := fmt.Sprintf("0.0.0.0:%d", port)
addr := webBindAddr(port)
ln, err := net.Listen("tcp", addr)
if err != nil {
@@ -96,10 +96,23 @@ func cmdWeb(args []string) error {
// clients tiers, en plein milieu d'une génération.
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
// SSE et les binaires passent tels quels (voir web_gzip.go).
srv := &http.Server{Handler: gzipHandler(mux), ReadHeaderTimeout: 10 * time.Second}
// withInternalAPI : l'API interne de la façade Rust (web_internal.go) n'est
// branchée QUE sur cette écoute directe — le tunnel sert le mux nu.
srv := &http.Server{Handler: gzipHandler(withInternalAPI(mux)), ReadHeaderTimeout: 10 * time.Second}
return srv.Serve(ln)
}
// webBindAddr renvoie l'adresse d'écoute de l'UI. LOKI_WEB_BIND (défaut
// 0.0.0.0) existe pour la façade Rust du conteneur (rust/loki-rs) : elle prend
// le port public et lance `loki web` en local seulement, sur 127.0.0.1.
func webBindAddr(port int) string {
host := strings.TrimSpace(os.Getenv("LOKI_WEB_BIND"))
if host == "" {
host = "0.0.0.0"
}
return net.JoinHostPort(host, strconv.Itoa(port))
}
// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être
// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans
// repasser par un écouteur TCP local.
+720
View File
@@ -0,0 +1,720 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"libc",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "loki-rs"
version = "0.1.0"
dependencies = [
"bytes",
"flate2",
"futures-util",
"getrandom",
"hex",
"http-body-util",
"hyper",
"hyper-util",
"libc",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "want"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+7
View File
@@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = ["loki-rs"]
[profile.release]
strip = true
lto = "thin"
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "loki-rs"
version = "0.1.0"
edition = "2024"
[dependencies]
bytes = "1.12.1"
flate2 = "1.1.10"
getrandom = "0.4.3"
hex = "0.4.3"
http-body-util = "0.1.5"
hyper = { version = "1.12.0", features = ["http1", "server", "client"] }
hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.11.1"
tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "process", "sync", "fs"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
futures-util = "0.3.34"
tempfile = "3.27.0"
tokio-tungstenite = "0.30.0"
+47
View File
@@ -0,0 +1,47 @@
//! Embarque l'UI web dans le binaire, depuis la MÊME source que le `go:embed`
//! du serveur Go (`internal/loki/ui/`) : les deux binaires d'un build servent
//! donc toujours la même interface. Génère `$OUT_DIR/ui_assets.rs`.
use std::fmt::Write as _;
use std::path::PathBuf;
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let ui = manifest.join("../../internal/loki/ui");
let ui = ui
.canonicalize()
.unwrap_or_else(|e| panic!("UI introuvable ({}) : {e}", ui.display()));
println!("cargo:rerun-if-changed={}", ui.display());
let mut out = String::new();
for name in [
"index.html",
"marked.min.js",
"sw.js",
"manifest.webmanifest",
] {
let p = ui.join(name);
assert!(p.is_file(), "fichier d'UI manquant : {}", p.display());
println!("cargo:rerun-if-changed={}", p.display());
let ident = name.replace(['.', '-'], "_").to_uppercase();
writeln!(out, "pub static {ident}: &[u8] = include_bytes!({:?});", p).unwrap();
}
let fonts_dir = ui.join("fonts");
println!("cargo:rerun-if-changed={}", fonts_dir.display());
let mut fonts: Vec<PathBuf> = std::fs::read_dir(&fonts_dir)
.unwrap_or_else(|e| panic!("{} : {e}", fonts_dir.display()))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "woff2"))
.collect();
fonts.sort();
out.push_str("pub static FONTS: &[(&str, &[u8])] = &[\n");
for p in &fonts {
let name = p.file_name().unwrap().to_str().unwrap();
writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap();
}
out.push_str("];\n");
let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
std::fs::write(dst, out).unwrap();
}
+382
View File
@@ -0,0 +1,382 @@
//! Authentification de l'API de pilotage, portée de `web_auth.go`
//! (`requireWebAuth`, `crossSiteReject`, `localHostName`, `checkBearer`).
//!
//! La clé de pilotage vit dans la base du Go : elle est demandée à son API
//! interne (`/internal/v1/auth`, voir `web_internal.go`) à CHAQUE requête,
//! comme le Go la relit à chaque requête — un changement de clé prend effet
//! immédiatement. Si l'API interne ne répond pas proprement, on FERME (503) :
//! une panne ne doit jamais désarmer l'authentification.
//!
//! `X-Loki-Via` (trafic du tunnel) n'est jamais consulté ici : la façade
//! l'efface à l'entrée, et le tunnel ne passe pas par elle.
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Request, Response, StatusCode};
use hyper_util::client::legacy::{Client, connect::HttpConnector};
use hyper_util::rt::TokioExecutor;
use serde::Deserialize;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use crate::proxy::Body;
pub const INTERNAL_HEADER: &str = "x-loki-internal";
pub const INTERNAL_ENV: &str = "LOKI_INTERNAL_TOKEN";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct AuthInfo {
pub web_key: String,
pub version: String,
}
/// Client de l'API interne du Go.
pub struct InternalClient {
client: Client<HttpConnector, Empty<Bytes>>,
backend: SocketAddr,
token: String,
}
impl InternalClient {
pub fn new(backend: SocketAddr, token: String) -> Self {
InternalClient {
client: Client::builder(TokioExecutor::new()).build_http(),
backend,
token,
}
}
pub async fn auth(&self) -> Result<AuthInfo, String> {
let req = Request::get(format!("http://{}/internal/v1/auth", self.backend))
.header(INTERNAL_HEADER, &self.token)
.body(Empty::new())
.map_err(|e| e.to_string())?;
let fut = async {
let resp = self.client.request(req).await.map_err(|e| e.to_string())?;
if resp.status() != StatusCode::OK {
return Err(format!("API interne : {}", resp.status()));
}
let body = resp
.into_body()
.collect()
.await
.map_err(|e| e.to_string())?;
serde_json::from_slice(&body.to_bytes()).map_err(|e| e.to_string())
};
tokio::time::timeout(Duration::from_secs(5), fut)
.await
.map_err(|_| "API interne : délai dépassé".to_string())?
}
}
/// Ce qu'il faut savoir de l'hôte pour juger qu'un nom est « local ».
#[derive(Debug, Clone, Default)]
pub struct HostEnv {
/// Nom de la machine (du conteneur), en minuscules.
pub hostname: Option<String>,
/// `LOKI_TRUSTED_HOSTS`, en minuscules.
pub trusted: Vec<String>,
}
impl HostEnv {
pub fn from_env() -> Self {
let trusted = std::env::var("LOKI_TRUSTED_HOSTS")
.unwrap_or_default()
.split(',')
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
.collect();
HostEnv {
hostname: hostname().map(|h| h.to_lowercase()),
trusted,
}
}
}
fn hostname() -> Option<String> {
// Ce que lit os.Hostname du Go sous Linux.
let h = std::fs::read_to_string("/proc/sys/kernel/hostname")
.ok()
.or_else(|| std::env::var("HOSTNAME").ok())
.or_else(|| std::env::var("COMPUTERNAME").ok())?;
let h = h.trim().to_string();
(!h.is_empty()).then_some(h)
}
/// Vérifie la requête comme `requireWebAuth` : `None` = autorisée, sinon la
/// réponse de refus.
pub fn authorize(
headers: &HeaderMap,
info: &Result<AuthInfo, String>,
env: &HostEnv,
) -> Option<Response<Body>> {
let info = match info {
Ok(i) => i,
Err(e) => {
eprintln!("[loki-rs] clé de pilotage illisible : {e}");
return Some(json_error(
StatusCode::SERVICE_UNAVAILABLE,
"configuration illisible — réessaie dans un instant",
));
}
};
let keyed = !info.web_key.is_empty();
if let Some(msg) = cross_site_reject(headers, keyed, env) {
return Some(json_error(StatusCode::FORBIDDEN, &msg));
}
if !keyed || check_bearer(headers, &info.web_key) {
return None;
}
let mut r = json_error(StatusCode::UNAUTHORIZED, "non autorisé");
r.headers_mut().insert(
header::WWW_AUTHENTICATE,
HeaderValue::from_static(r#"Bearer realm="loki""#),
);
Some(r)
}
/// Raison du refus d'une requête venue d'un site tiers, ou `None`.
pub fn cross_site_reject(headers: &HeaderMap, keyed: bool, env: &HostEnv) -> Option<String> {
let get = |k: header::HeaderName| headers.get(k).and_then(|v| v.to_str().ok());
let host = get(header::HOST).unwrap_or("");
if get(header::HeaderName::from_static("sec-fetch-site"))
.is_some_and(|v| v.eq_ignore_ascii_case("cross-site"))
{
return Some("requête d'un site tiers refusée".into());
}
match get(header::ORIGIN) {
Some("null") => return Some("origine non autorisée".into()),
Some(o) if !o.is_empty() && !origin_host(o).eq_ignore_ascii_case(host) => {
return Some(format!("origine non autorisée : {o}"));
}
_ => {}
}
if !keyed && !local_host_name(host, env) {
return Some(format!(
"hôte « {host} » non autorisé sans clé de pilotage — définis-en une \
(loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS"
));
}
None
}
/// Partie « hôte[:port] » d'une origine, comme `url.Parse(o).Host` en Go.
fn origin_host(o: &str) -> &str {
let rest = match o.find("://") {
Some(i) => &o[i + 3..],
None => match o.strip_prefix("//") {
Some(r) => r,
None => return "",
},
};
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let auth = &rest[..end];
auth.rsplit_once('@').map(|(_, h)| h).unwrap_or(auth)
}
/// L'hôte désigne-t-il la machine ou le réseau local (et non un domaine
/// public, seul utilisable pour un DNS rebinding) ?
pub fn local_host_name(hostport: &str, env: &HostEnv) -> bool {
let h = split_host(hostport).unwrap_or(hostport);
let h = h.trim_matches(['[', ']', '.']).to_lowercase();
if h.is_empty() || h.parse::<IpAddr>().is_ok() || !h.contains('.') {
return true;
}
const SUFFIXES: [&str; 7] = [
".localhost",
".local",
".lan",
".home",
".internal",
".home.arpa",
".localdomain",
];
if SUFFIXES.iter().any(|s| h.ends_with(s)) {
return true;
}
if let Some(me) = env.hostname.as_deref().filter(|m| !m.is_empty())
&& (h == me || h.starts_with(&format!("{me}.")))
{
return true;
}
env.trusted.contains(&h)
}
/// `net.SplitHostPort` réduit à ce qui sert ici : l'hôte, si `s` porte un port.
fn split_host(s: &str) -> Option<&str> {
if let Some(rest) = s.strip_prefix('[') {
let (host, after) = rest.split_once(']')?;
return after.starts_with(':').then_some(host);
}
let (host, port) = s.split_once(':')?;
(!port.contains(':')).then_some(host)
}
/// `Authorization: Bearer <clé>`, comparaison en temps constant.
pub fn check_bearer(headers: &HeaderMap, key: &str) -> bool {
let Some(got) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
return false;
};
constant_time_eq(got.trim().as_bytes(), key.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// JSON comme `sendJSON` du Go (json.Encoder : saut de ligne final).
pub fn json(status: StatusCode, v: &serde_json::Value) -> Response<Body> {
let mut s = serde_json::to_string(v).unwrap_or_else(|_| "{}".into());
s.push('\n');
let mut r = Response::new(Full::new(Bytes::from(s)).map_err(|n| match n {}).boxed());
*r.status_mut() = status;
r.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
r
}
fn json_error(status: StatusCode, msg: &str) -> Response<Body> {
json(status, &serde_json::json!({ "error": msg }))
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> HostEnv {
HostEnv {
hostname: Some("tour-unraid".into()),
trusted: vec!["loki.exemple.fr".into()],
}
}
fn headers(host: &str, origin: &str, fetch_site: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(header::HOST, host.parse().unwrap());
if !origin.is_empty() {
h.insert(header::ORIGIN, origin.parse().unwrap());
}
if !fetch_site.is_empty() {
h.insert("sec-fetch-site", fetch_site.parse().unwrap());
}
h
}
/// Repris un pour un de TestCrossSiteReject (web_auth_crosssite_test.go),
/// hors cas « tunnel » (le tunnel ne traverse pas la façade).
#[test]
fn cross_site_comme_le_go() {
#[rustfmt::skip]
let cas: &[(&str, &str, &str, &str, bool, bool)] = &[
// nom, host, origin, sec-fetch-site, keyed, refusé
("curl sur IP", "192.168.1.10:8090", "", "", false, false),
("localhost", "localhost:8090", "http://localhost:8090", "", false, false),
("nom sans point", "tower:8090", "http://tower:8090", "", false, false),
("nom .local", "tower.local:8090", "", "", false, false),
("origine tierce", "192.168.1.10:8090", "http://evil.example", "", false, true),
("origine null", "localhost:8090", "null", "", false, true),
("sec-fetch cross-site", "localhost:8090", "", "cross-site", false, true),
("rebinding sans clé", "evil.example:8090", "http://evil.example:8090", "", false, true),
("domaine avec clé", "evil.example:8090", "", "", true, false),
("domaine de confiance", "loki.exemple.fr", "https://loki.exemple.fr", "", false, false),
];
for (nom, host, origin, fs, keyed, refuse) in cas {
let got = cross_site_reject(&headers(host, origin, fs), *keyed, &env()).is_some();
assert_eq!(got, *refuse, "{nom}");
}
}
#[test]
fn messages_identiques_au_go() {
let m = cross_site_reject(&headers("evil.example", "", ""), false, &env()).unwrap();
assert_eq!(
m,
"hôte « evil.example » non autorisé sans clé de pilotage — définis-en une \
(loki set-web-key) ou ajoute ce nom à LOKI_TRUSTED_HOSTS"
);
let m = cross_site_reject(&headers("x", "http://evil", ""), true, &env()).unwrap();
assert_eq!(m, "origine non autorisée : http://evil");
}
#[test]
fn noms_locaux() {
let e = env();
for h in [
"",
"10.0.0.2",
"[::1]:8090",
"::1",
"tower",
"nas.lan",
"x.home.arpa:443",
"TOUR-UNRAID",
"tour-unraid.maison.fr",
"loki.exemple.fr:443",
"localhost.",
] {
assert!(local_host_name(h, &e), "{h} devrait être local");
}
for h in [
"evil.example",
"evil.example:8090",
"loki.exemple.fr.evil.com",
] {
assert!(!local_host_name(h, &e), "{h} ne devrait pas être local");
}
}
#[test]
fn hote_d_origine() {
assert_eq!(origin_host("https://a.b:8090"), "a.b:8090");
assert_eq!(origin_host("http://u:p@a.b/x?y"), "a.b");
assert_eq!(origin_host("//a.b"), "a.b");
assert_eq!(origin_host("a.b"), "");
}
#[test]
fn bearer() {
let mut h = HeaderMap::new();
assert!(!check_bearer(&h, "k"));
h.insert(header::AUTHORIZATION, "Bearer k ".parse().unwrap());
assert!(check_bearer(&h, "k"));
assert!(!check_bearer(&h, "kk"));
h.insert(header::AUTHORIZATION, "bearer k".parse().unwrap());
assert!(!check_bearer(&h, "k"));
}
#[test]
fn autorisation() {
let e = env();
let ok = |k: &str| {
Ok(AuthInfo {
web_key: k.into(),
version: "0.15.0".into(),
})
};
let h = headers("localhost:8090", "", "");
assert!(authorize(&h, &ok(""), &e).is_none());
let r = authorize(&h, &ok("s"), &e).unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
r.headers()[header::WWW_AUTHENTICATE],
r#"Bearer realm="loki""#
);
let mut hb = h.clone();
hb.insert(header::AUTHORIZATION, "Bearer s".parse().unwrap());
assert!(authorize(&hb, &ok("s"), &e).is_none());
let r = authorize(&hb, &Err("panne".into()), &e).unwrap();
assert_eq!(r.status(), StatusCode::SERVICE_UNAVAILABLE);
let r = authorize(&headers("evil.example", "", ""), &ok(""), &e).unwrap();
assert_eq!(r.status(), StatusCode::FORBIDDEN);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! Supervision du serveur Go (`loki web`) lancé derrière la façade.
//!
//! Le Go reste propriétaire de presque tout au lot 1 : s'il meurt, la façade
//! le relance (attente croissante 1 s → 30 s, remise à 1 s après une minute
//! de vie) et répond 502 entre-temps au lieu de tomber avec lui.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::process::{Child, Command};
use tokio::sync::oneshot;
const MIN_DELAY: Duration = Duration::from_secs(1);
const MAX_DELAY: Duration = Duration::from_secs(30);
const STABLE_AFTER: Duration = Duration::from_secs(60);
/// Attente avant la relance suivante, connaissant la précédente et la durée
/// de vie du processus qui vient de mourir.
pub fn next_delay(prev: Duration, lived: Duration) -> Duration {
if lived >= STABLE_AFTER {
MIN_DELAY
} else {
(prev * 2).clamp(MIN_DELAY, MAX_DELAY)
}
}
/// Binaire Go à lancer : `LOKI_GO_BIN`, sinon `loki` à côté de l'exécutable
/// courant (les deux viennent toujours du même dossier, cf. `which-bin`).
pub fn go_bin(env_value: Option<String>, exe: &Path) -> PathBuf {
if let Some(p) = env_value.filter(|v| !v.trim().is_empty()) {
return PathBuf::from(p);
}
let name = if cfg!(windows) { "loki.exe" } else { "loki" };
exe.parent().unwrap_or(Path::new(".")).join(name)
}
pub struct Supervisor {
stop: Option<oneshot::Sender<Duration>>,
done: tokio::task::JoinHandle<()>,
}
impl Supervisor {
/// Lance `<go_bin> web <port>` en écoute locale et le maintient en vie.
/// `env` : variables ajoutées à l'environnement du Go (jeton interne…).
pub fn spawn(go_bin: PathBuf, port: u16, env: Vec<(String, String)>) -> Supervisor {
let (tx, mut rx) = oneshot::channel::<Duration>();
let done = tokio::spawn(async move {
let mut delay = Duration::ZERO;
loop {
let started = Instant::now();
let mut child = match launch(&go_bin, port, &env) {
Ok(c) => c,
Err(e) => {
eprintln!(
"[loki-rs] lancement de {} impossible : {e}",
go_bin.display()
);
delay = next_delay(delay, Duration::ZERO);
tokio::select! {
_ = tokio::time::sleep(delay) => continue,
_ = &mut rx => return,
}
}
};
tokio::select! {
status = child.wait() => {
let lived = started.elapsed();
delay = next_delay(delay, lived);
let status = match status {
Ok(s) => s.to_string(),
Err(e) => e.to_string(),
};
eprintln!(
"[loki-rs] serveur Go arrêté ({status}) après {lived:?} — relance dans {delay:?}"
);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = &mut rx => return,
}
}
grace = &mut rx => {
stop_child(&mut child, grace.unwrap_or(MIN_DELAY)).await;
return;
}
}
}
});
Supervisor {
stop: Some(tx),
done,
}
}
/// Arrête le Go : SIGTERM, puis SIGKILL passé `grace`.
pub async fn shutdown(mut self, grace: Duration) {
if let Some(tx) = self.stop.take() {
let _ = tx.send(grace);
}
let _ = (&mut self.done).await;
}
}
fn launch(go_bin: &Path, port: u16, env: &[(String, String)]) -> std::io::Result<Child> {
Command::new(go_bin)
.envs(env.iter().map(|(k, v)| (k, v)))
.arg("web")
.arg(port.to_string())
.env("LOKI_WEB_BIND", "127.0.0.1")
// stdin fermé : `loki web` ne doit jamais attendre une réponse au
// clavier (résolution de conflit de port).
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
}
async fn stop_child(child: &mut Child, grace: Duration) {
#[cfg(unix)]
if let Some(pid) = child.id() {
// SAFETY: kill(2) sur le PID de notre propre enfant, pas encore récolté.
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
if tokio::time::timeout(grace, child.wait()).await.is_ok() {
return;
}
}
#[cfg(not(unix))]
let _ = grace;
let _ = child.kill().await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attente_croissante_puis_remise_a_zero() {
let s = Duration::from_secs;
assert_eq!(next_delay(Duration::ZERO, s(0)), s(1));
assert_eq!(next_delay(s(1), s(2)), s(2));
assert_eq!(next_delay(s(16), s(2)), s(30));
assert_eq!(next_delay(s(30), s(2)), s(30));
assert_eq!(next_delay(s(30), s(61)), s(1));
}
#[test]
fn binaire_go_par_defaut_a_cote_de_l_exe() {
let exe = Path::new("/usr/local/bin/loki-rs");
let name = if cfg!(windows) { "loki.exe" } else { "loki" };
let dir = Path::new("/usr/local/bin");
assert_eq!(go_bin(None, exe), dir.join(name));
assert_eq!(go_bin(Some(" ".into()), exe), dir.join(name));
assert_eq!(
go_bin(Some("/x/loki".into()), exe),
PathBuf::from("/x/loki")
);
}
}
+13
View File
@@ -0,0 +1,13 @@
//! loki-rs — façade Rust de Loki (migration Go → Rust par lots).
//!
//! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ;
//! - `child` : supervision du processus `loki web` (Go) ;
//! - `ui` : UI web statique embarquée (lot 2) ;
//! - `auth` : authentification de l'API de pilotage (lot 2) ;
//! - `update` : mise à jour des binaires au démarrage du conteneur.
pub mod auth;
pub mod child;
pub mod proxy;
pub mod ui;
pub mod update;
+175
View File
@@ -0,0 +1,175 @@
//! loki-rs — façade Rust de Loki.
//!
//! ```text
//! loki-rs serve façade sur LOKI_WEB_PORT (8090), Go sur 127.0.0.1:LOKI_GO_PORT (8091)
//! loki-rs self-update met à jour les binaires dans $LOKI_HOME/bin (jamais bloquant)
//! loki-rs which-bin dossier d'où prendre loki et loki-rs
//! loki-rs version
//! ```
use loki_rs::{auth, child, proxy, update};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{Duration, Instant};
fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_default();
match cmd.as_str() {
"serve" => serve(),
"self-update" => {
let outcome = update::run(&update::UpdateConfig::from_env());
eprintln!("[loki-rs update] {}", outcome.message());
ExitCode::SUCCESS
}
"which-bin" => {
let cfg = update::UpdateConfig::from_env();
let dir = update::state::select_dir(
&cfg.data_bin,
&image_dir(),
cfg.image_build,
cfg.image_abi,
);
println!("{}", dir.display());
ExitCode::SUCCESS
}
"version" | "--version" | "-V" => {
let cfg = update::UpdateConfig::from_env();
println!(
"loki-rs {} (build image {}, ABI {})",
env!("CARGO_PKG_VERSION"),
cfg.image_build,
cfg.image_abi
);
ExitCode::SUCCESS
}
_ => {
eprintln!("usage : loki-rs serve | self-update | which-bin | version");
ExitCode::from(2)
}
}
}
fn exe() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("loki-rs"))
}
fn image_dir() -> PathBuf {
exe()
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
fn env_port(key: &str, default: u16) -> u16 {
std::env::var(key)
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(default)
}
fn serve() -> ExitCode {
let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("[loki-rs] runtime : {e}");
return ExitCode::FAILURE;
}
};
rt.block_on(async {
let web_port = env_port("LOKI_WEB_PORT", 8090);
let go_port = env_port("LOKI_GO_PORT", 8091);
let go_bin = child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe());
let backend = SocketAddr::from(([127, 0, 0, 1], go_port));
eprintln!(
"[loki-rs] serveur Go : {} web {go_port} (local)",
go_bin.display()
);
// Jeton de l'API interne du Go : tiré au hasard à chaque démarrage,
// connu seulement de la façade et du Go qu'elle lance.
let token = match internal_token() {
Ok(t) => t,
Err(e) => {
eprintln!("[loki-rs] aléa système indisponible : {e}");
return ExitCode::FAILURE;
}
};
let sup = child::Supervisor::spawn(
go_bin,
go_port,
vec![(auth::INTERNAL_ENV.to_string(), token.clone())],
);
wait_for(backend, Duration::from_secs(30)).await;
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[loki-rs] écoute sur {addr} impossible : {e}");
sup.shutdown(Duration::from_secs(10)).await;
return ExitCode::FAILURE;
}
};
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
let cfg = proxy::Config {
backend,
internal_token: token,
host_env: auth::HostEnv::from_env(),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
sup.shutdown(Duration::from_secs(10)).await;
match res {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("[loki-rs] {e}");
ExitCode::FAILURE
}
}
})
}
fn internal_token() -> Result<String, getrandom::Error> {
let mut b = [0u8; 32];
getrandom::fill(&mut b)?;
Ok(hex::encode(b))
}
/// Attend que le Go accepte les connexions, sans bloquer au-delà de `max` :
/// la façade ouvre quand même et répond 502 en attendant.
async fn wait_for(addr: SocketAddr, max: Duration) {
let t0 = Instant::now();
while t0.elapsed() < max {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
eprintln!("[loki-rs] le serveur Go ne répond pas encore — façade ouverte quand même");
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut term) => {
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
}
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
+282
View File
@@ -0,0 +1,282 @@
//! Façade HTTP : reçoit tout le trafic public. Les routes déjà réécrites en
//! Rust sont servies ici (lot 2 : UI statique, `/api/ping`) ; tout le reste
//! est relayé au serveur Go (`loki web` sur 127.0.0.1).
//!
//! Règles, héritées de `web_server.go` :
//! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ;
//! - aucun délai d'écriture : un flux reste ouvert tant que la page l'écoute ;
//! - en-têtes de bout en bout transmis intacts (`Host`, `Authorization`,
//! `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`…) : le Go s'en sert pour ses
//! contrôles d'accès ; seuls les en-têtes hop-by-hop sont retirés ;
//! - `X-Loki-Via` entrant effacé : il marque le trafic du tunnel du relais et
//! dispense du contrôle d'hôte anti-DNS-rebinding — un client ne doit jamais
//! pouvoir le poser lui-même.
use bytes::Bytes;
use http_body_util::{BodyExt, Full, combinators::BoxBody};
use hyper::body::Incoming;
use hyper::header::{self, HeaderMap, HeaderName, HeaderValue};
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode, Uri};
use hyper_util::client::legacy::{Client, connect::HttpConnector};
use hyper_util::rt::{TokioExecutor, TokioIo};
use std::convert::Infallible;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use crate::auth::{self, HostEnv, INTERNAL_HEADER, InternalClient};
use crate::ui;
pub type Body = BoxBody<Bytes, hyper::Error>;
/// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`).
pub const VIA_TUNNEL: &str = "x-loki-via";
const HOP_BY_HOP: [&str; 8] = [
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
/// Réglages de la façade.
pub struct Config {
/// Serveur Go (`loki web`), en écoute locale.
pub backend: SocketAddr,
/// Jeton de l'API interne du Go (`LOKI_INTERNAL_TOKEN`).
pub internal_token: String,
pub host_env: HostEnv,
}
struct Proxy {
client: Client<HttpConnector, Incoming>,
backend: SocketAddr,
internal: InternalClient,
host_env: HostEnv,
}
/// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine.
pub async fn serve(
listener: TcpListener,
cfg: Config,
shutdown: impl Future<Output = ()>,
) -> std::io::Result<()> {
let client = Client::builder(TokioExecutor::new()).build_http();
let proxy = Arc::new(Proxy {
client,
backend: cfg.backend,
internal: InternalClient::new(cfg.backend, cfg.internal_token),
host_env: cfg.host_env,
});
tokio::pin!(shutdown);
loop {
tokio::select! {
_ = &mut shutdown => return Ok(()),
res = listener.accept() => {
let stream = match res {
Ok((s, _)) => s,
Err(e) => {
// EMFILE & co : on ne lâche pas l'écoute pour autant.
eprintln!("[loki-rs] accept : {e}");
tokio::time::sleep(Duration::from_millis(50)).await;
continue;
}
};
let _ = stream.set_nodelay(true);
let p = proxy.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| {
let p = p.clone();
async move { Ok::<_, Infallible>(p.handle(req).await) }
});
let _ = hyper::server::conn::http1::Builder::new()
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(Duration::from_secs(10))
.serve_connection(TokioIo::new(stream), svc)
.with_upgrades()
.await;
});
}
}
}
}
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
// Jamais de confiance accordée sur la foi d'un en-tête du client :
// ni « je viens du tunnel », ni « je suis la façade ».
req.headers_mut().remove(VIA_TUNNEL);
req.headers_mut().remove(INTERNAL_HEADER);
let path = req.uri().path();
// L'API interne du Go n'est jamais joignable de l'extérieur.
if path.starts_with("/internal/") {
return plain(StatusCode::NOT_FOUND, "404 page not found");
}
if let Some(resp) = ui::serve(req.method(), path, req.headers()) {
return resp;
}
if path == "/api/ping" {
return self.ping(req.headers()).await;
}
self.forward(req).await
}
/// `/api/ping` : vérifie la connectivité ET la clé (200 = bonne clé,
/// 401 = mauvaise clé), comme `handlePing`.
async fn ping(&self, headers: &HeaderMap) -> Response<Body> {
let info = self.internal.auth().await;
if let Some(refus) = auth::authorize(headers, &info, &self.host_env) {
return refus;
}
let version = info.map(|i| i.version).unwrap_or_default();
auth::json(
StatusCode::OK,
&serde_json::json!({ "ok": true, "service": "loki", "version": version }),
)
}
/// Relais vers le Go (HTTP, SSE, WebSocket).
async fn forward(&self, mut req: Request<Incoming>) -> Response<Body> {
let upgrade = upgrade_protocol(req.headers());
strip_hop_by_hop(req.headers_mut());
let client_upgrade = upgrade.as_ref().map(|proto| {
let h = req.headers_mut();
h.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
h.insert(header::UPGRADE, proto.clone());
hyper::upgrade::on(&mut req)
});
let pq = req
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/");
let uri: Uri = match format!("http://{}{}", self.backend, pq).parse() {
Ok(u) => u,
Err(_) => return plain(StatusCode::BAD_REQUEST, "URI invalide"),
};
*req.uri_mut() = uri;
let mut resp = match self.client.request(req).await {
Ok(r) => r,
Err(e) => {
eprintln!("[loki-rs] backend Go injoignable : {e}");
return plain(
StatusCode::BAD_GATEWAY,
"Loki (serveur Go) redémarre — réessaie dans un instant.",
);
}
};
if let Some(client_upgrade) = client_upgrade
&& resp.status() == StatusCode::SWITCHING_PROTOCOLS
{
let backend_upgrade = hyper::upgrade::on(&mut resp);
tokio::spawn(async move {
match (client_upgrade.await, backend_upgrade.await) {
(Ok(a), Ok(b)) => {
let (mut a, mut b) = (TokioIo::new(a), TokioIo::new(b));
let _ = tokio::io::copy_bidirectional(&mut a, &mut b).await;
}
(Err(e), _) | (_, Err(e)) => eprintln!("[loki-rs] upgrade : {e}"),
}
});
// 101 : Connection/Upgrade font partie de la poignée de main.
return resp.map(BodyExt::boxed);
}
strip_hop_by_hop(resp.headers_mut());
resp.map(BodyExt::boxed)
}
}
/// Protocole demandé si la requête est une demande d'upgrade (WebSocket).
fn upgrade_protocol(h: &HeaderMap) -> Option<HeaderValue> {
let wants = h
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(','))
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"));
if wants {
h.get(header::UPGRADE).cloned()
} else {
None
}
}
/// Retire les en-têtes propres à une connexion (RFC 9110 §7.6.1), y compris
/// ceux que `Connection` désigne.
pub fn strip_hop_by_hop(h: &mut HeaderMap) {
let listed: Vec<HeaderName> = h
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(','))
.filter_map(|t| HeaderName::from_bytes(t.trim().as_bytes()).ok())
.collect();
for n in listed {
h.remove(n);
}
for n in HOP_BY_HOP {
h.remove(n);
}
}
fn plain(status: StatusCode, msg: &'static str) -> Response<Body> {
let mut r = Response::new(
Full::new(Bytes::from_static(msg.as_bytes()))
.map_err(|n| match n {})
.boxed(),
);
*r.status_mut() = status;
r.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retire_les_hop_by_hop_et_ceux_listes() {
let mut h = HeaderMap::new();
h.insert(
"connection",
HeaderValue::from_static("keep-alive, x-perso"),
);
h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
h.insert("x-perso", HeaderValue::from_static("1"));
h.insert("transfer-encoding", HeaderValue::from_static("chunked"));
h.insert("authorization", HeaderValue::from_static("Bearer k"));
strip_hop_by_hop(&mut h);
assert_eq!(h.len(), 1);
assert!(h.contains_key("authorization"));
}
#[test]
fn detecte_l_upgrade() {
let mut h = HeaderMap::new();
h.insert(
"connection",
HeaderValue::from_static("keep-alive, Upgrade"),
);
h.insert("upgrade", HeaderValue::from_static("websocket"));
assert_eq!(
upgrade_protocol(&h),
Some(HeaderValue::from_static("websocket"))
);
h.insert("connection", HeaderValue::from_static("keep-alive"));
assert_eq!(upgrade_protocol(&h), None);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! UI web statique, servie par la façade (lot 2). Mêmes en-têtes que les
//! routes de `web_server.go`, même compression que `web_gzip.go`.
use crate::proxy::Body;
use bytes::Bytes;
use flate2::Compression;
use flate2::write::GzEncoder;
use http_body_util::{BodyExt, Full};
use hyper::header::{self, HeaderMap, HeaderValue};
use hyper::{Method, Response};
use std::io::Write;
use std::sync::OnceLock;
mod assets {
include!(concat!(env!("OUT_DIR"), "/ui_assets.rs"));
}
struct Asset {
body: &'static [u8],
ctype: &'static str,
cache: &'static str,
/// Version gzip, calculée à la première demande qui l'accepte.
gz: OnceLock<Vec<u8>>,
}
impl Asset {
const fn new(body: &'static [u8], ctype: &'static str, cache: &'static str) -> Self {
Asset {
body,
ctype,
cache,
gz: OnceLock::new(),
}
}
}
const NO_STORE: &str = "no-store, max-age=0";
static INDEX: Asset = Asset::new(assets::INDEX_HTML, "text/html; charset=utf-8", NO_STORE);
static MARKED: Asset = Asset::new(
assets::MARKED_MIN_JS,
"application/javascript",
"public, max-age=86400",
);
static SW: Asset = Asset::new(assets::SW_JS, "application/javascript", NO_STORE);
static MANIFEST: Asset = Asset::new(
assets::MANIFEST_WEBMANIFEST,
"application/manifest+json",
"public, max-age=3600",
);
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Response<Body>> {
let head = method == Method::HEAD;
if !head && method != Method::GET {
return None;
}
// Comme gzipHandler : HEAD jamais compressé, et Vary posé dès que le
// client accepte gzip (même pour une réponse qui ne l'est pas).
let accepts_gzip = !head
&& headers
.get_all(header::ACCEPT_ENCODING)
.iter()
.filter_map(|v| v.to_str().ok())
.any(|v| v.contains("gzip"));
let mut resp = match path {
"/" | "/index.html" => asset(&INDEX, accepts_gzip),
"/marked.min.js" => asset(&MARKED, accepts_gzip),
"/sw.js" => {
let mut r = asset(&SW, accepts_gzip);
r.headers_mut()
.insert("service-worker-allowed", HeaderValue::from_static("/"));
r
}
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
p if p.starts_with("/fonts/") => font(p)?,
_ => return None,
};
if accepts_gzip {
resp.headers_mut()
.append(header::VARY, HeaderValue::from_static("Accept-Encoding"));
}
if head {
let len = resp.headers().get(header::CONTENT_LENGTH).cloned();
let mut empty = Response::new(full(Bytes::new()));
*empty.headers_mut() = std::mem::take(resp.headers_mut());
if let Some(len) = len {
empty.headers_mut().insert(header::CONTENT_LENGTH, len);
}
return Some(empty);
}
Some(resp)
}
fn asset(a: &'static Asset, gzip: bool) -> Response<Body> {
let (body, encoded) = if gzip {
(a.gz.get_or_init(|| compress(a.body)).as_slice(), true)
} else {
(a.body, false)
};
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static(a.ctype));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(a.cache));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
if encoded {
h.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
}
r
}
/// `/fonts/<nom>.woff2` — comme le Go, seul le dernier segment compte.
/// Jamais compressées : woff2 l'est déjà.
fn font(path: &str) -> Option<Response<Body>> {
let name = path.rsplit('/').next()?;
if !name.ends_with(".woff2") {
return None;
}
let (_, body) = assets::FONTS.iter().find(|(n, _)| *n == name)?;
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
h.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
Some(r)
}
fn compress(b: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
enc.write_all(b).expect("gzip en mémoire");
enc.finish().expect("gzip en mémoire")
}
fn full(b: Bytes) -> Body {
Full::new(b).map_err(|n| match n {}).boxed()
}
/// Taille de l'index embarqué (tests).
pub fn index_bytes() -> &'static [u8] {
INDEX.body
}
#[cfg(test)]
mod tests {
use super::*;
fn get(path: &str, gzip: bool) -> Option<Response<Body>> {
let mut h = HeaderMap::new();
if gzip {
h.insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("gzip, br"),
);
}
serve(&Method::GET, path, &h)
}
fn hv<'a>(r: &'a Response<Body>, k: &str) -> &'a str {
r.headers()
.get(k)
.map(|v| v.to_str().unwrap())
.unwrap_or("")
}
#[test]
fn en_tetes_comme_le_go() {
let r = get("/", false).unwrap();
assert_eq!(hv(&r, "content-type"), "text/html; charset=utf-8");
assert_eq!(hv(&r, "cache-control"), "no-store, max-age=0");
assert_eq!(hv(&r, "vary"), "");
let r = get("/sw.js", false).unwrap();
assert_eq!(hv(&r, "service-worker-allowed"), "/");
assert_eq!(hv(&r, "cache-control"), "no-store, max-age=0");
let r = get("/marked.min.js", false).unwrap();
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
let r = get("/manifest.webmanifest", false).unwrap();
assert_eq!(hv(&r, "content-type"), "application/manifest+json");
}
#[test]
fn polices() {
let (name, _) = assets::FONTS[0];
let r = get(&format!("/fonts/{name}"), true).unwrap();
assert_eq!(hv(&r, "content-type"), "font/woff2");
assert_eq!(hv(&r, "content-encoding"), "");
assert_eq!(hv(&r, "vary"), "Accept-Encoding");
assert!(get("/fonts/absente.woff2", false).is_none());
assert!(get("/fonts/inter.ttf", false).is_none());
}
#[test]
fn gzip_si_accepte() {
let r = get("/", true).unwrap();
assert_eq!(hv(&r, "content-encoding"), "gzip");
assert_eq!(hv(&r, "vary"), "Accept-Encoding");
let len: usize = hv(&r, "content-length").parse().unwrap();
assert!(len < index_bytes().len() / 2, "gzip inefficace : {len}");
}
#[test]
fn head_et_methodes() {
let h = HeaderMap::new();
let r = serve(&Method::HEAD, "/", &h).unwrap();
assert_eq!(hv(&r, "content-length"), index_bytes().len().to_string());
assert!(serve(&Method::POST, "/", &h).is_none());
assert!(get("/api/status", false).is_none());
assert!(get("/index.htm", false).is_none());
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Mise à jour des binaires Loki au démarrage du conteneur.
//!
//! La CI publie à chaque push sur `main` une release GitHub `rolling` :
//! `loki-linux`, `loki-rs-linux`, `SHA256SUMS` puis `manifest.json` (en
//! dernier). Au boot, [`run`] compare le manifeste au build en service et, si
//! besoin, installe les deux binaires dans `<LOKI_HOME>/bin/<build>/` (voir
//! [`state`]). Rien ici ne doit bloquer le démarrage : toute erreur se solde
//! par [`Outcome::Failed`] et l'ancien état reste en place.
//!
//! Le transfert HTTPS passe par `curl` (présent dans l'image) : le binaire
//! n'embarque ainsi aucune pile TLS.
pub mod state;
pub mod sums;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use state::{Decision, Manifest};
pub const DEFAULT_URL: &str = "https://github.com/R0m1k3/Loki/releases/download/rolling";
pub const IMAGE_ABI_FILE: &str = "/etc/loki/IMAGE_ABI";
/// Nom de l'asset publié → nom du binaire installé.
pub const ASSETS: [(&str, &str); 2] = [
("loki-linux", state::GO_BIN),
("loki-rs-linux", state::RS_BIN),
];
const TMP_DIR: &str = ".tmp-download";
#[derive(Debug, Clone)]
pub struct UpdateConfig {
pub enabled: bool,
pub base_url: String,
pub data_bin: PathBuf,
pub image_build: u64,
pub image_abi: u32,
/// Durée maximale du téléchargement du manifeste et des sommes.
pub small_timeout: Duration,
/// Durée maximale de chaque binaire (un transfert figé est coupé bien
/// avant, cf. `--speed-limit`).
pub big_timeout: Duration,
}
impl UpdateConfig {
pub fn from_env() -> Self {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
let home = env("LOKI_HOME").unwrap_or_else(|| "/data".into());
let image_abi = fs::read_to_string(IMAGE_ABI_FILE)
.ok()
.or_else(|| env("LOKI_IMAGE_ABI"))
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
UpdateConfig {
enabled: !matches!(
env("LOKI_AUTO_UPDATE").as_deref().map(str::trim),
Some("0" | "false" | "off" | "no")
),
base_url: env("LOKI_UPDATE_URL").unwrap_or_else(|| DEFAULT_URL.into()),
data_bin: Path::new(&home).join("bin"),
image_build: env("LOKI_BUILD")
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0),
image_abi,
small_timeout: Duration::from_secs(20),
big_timeout: Duration::from_secs(600),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Disabled,
UpToDate(u64),
/// Version publiée incompatible avec l'image : il faut tirer l'image.
AbiMismatch {
remote_abi: u32,
image_abi: u32,
},
Installed(u64),
Failed(String),
}
impl Outcome {
/// Ligne de journal pour l'entrypoint.
pub fn message(&self) -> String {
match self {
Outcome::Disabled => "mise à jour au démarrage désactivée (LOKI_AUTO_UPDATE=0)".into(),
Outcome::UpToDate(b) => format!("binaires à jour (build {b})"),
Outcome::AbiMismatch {
remote_abi,
image_abi,
} if remote_abi < image_abi => format!(
"image plus récente que la release publiée (ABI {remote_abi}, image {image_abi}) \
— binaires de l'image conservés"
),
Outcome::AbiMismatch {
remote_abi,
image_abi,
} => format!(
"nouvelle version disponible mais elle exige une nouvelle image \
(ABI {remote_abi}, image {image_abi}) : docker compose pull"
),
Outcome::Installed(b) => format!("build {b} installé — actif dès ce démarrage"),
Outcome::Failed(e) => {
format!("mise à jour impossible ({e}) — binaires actuels conservés")
}
}
}
}
pub fn run(cfg: &UpdateConfig) -> Outcome {
if !cfg.enabled {
return Outcome::Disabled;
}
if let Err(e) = fs::create_dir_all(&cfg.data_bin) {
return Outcome::Failed(format!("{}: {e}", cfg.data_bin.display()));
}
let tmp = cfg.data_bin.join(TMP_DIR);
let _ = fs::remove_dir_all(&tmp);
let res = fs::create_dir_all(&tmp)
.map_err(|e| format!("{}: {e}", tmp.display()))
.and_then(|_| run_in(cfg, &tmp));
let _ = fs::remove_dir_all(&tmp);
res.unwrap_or_else(Outcome::Failed)
}
fn run_in(cfg: &UpdateConfig, tmp: &Path) -> Result<Outcome, String> {
let current = state::current(&cfg.data_bin, cfg.image_build, cfg.image_abi)
.map(|(m, _)| m.build)
.unwrap_or(cfg.image_build);
let man_path = tmp.join("manifest.json");
fetch(&cfg.base_url, "manifest.json", &man_path, cfg.small_timeout)?;
let remote = state::read_manifest(&man_path).ok_or("manifest.json illisible")?;
match state::decide(&remote, current, cfg.image_abi) {
Decision::AbiMismatch => {
return Ok(Outcome::AbiMismatch {
remote_abi: remote.abi,
image_abi: cfg.image_abi,
});
}
Decision::UpToDate => {
prune(&cfg.data_bin, current);
return Ok(Outcome::UpToDate(current));
}
Decision::Install => {}
}
let sums_path = tmp.join("SHA256SUMS");
fetch(&cfg.base_url, "SHA256SUMS", &sums_path, cfg.small_timeout)?;
let sums_text = fs::read_to_string(&sums_path).map_err(|e| format!("SHA256SUMS: {e}"))?;
let sums = sums::parse(&sums_text);
let stage = tmp.join("stage");
fs::create_dir_all(&stage).map_err(|e| e.to_string())?;
for (asset, local) in ASSETS {
let want = sums
.get(asset)
.ok_or(format!("SHA256SUMS sans entrée pour {asset}"))?;
let dst = stage.join(local);
fetch(&cfg.base_url, asset, &dst, cfg.big_timeout)?;
let got = sha256_file(&dst).map_err(|e| format!("{asset}: {e}"))?;
if &got != want {
return Err(format!("empreinte de {asset} invalide"));
}
make_executable(&dst).map_err(|e| format!("{asset}: {e}"))?;
}
write_manifest(&stage.join(state::RELEASE_FILE), &remote)?;
let dest = cfg.data_bin.join(remote.build.to_string());
let _ = fs::remove_dir_all(&dest);
fs::rename(&stage, &dest).map_err(|e| format!("{}: {e}", dest.display()))?;
// Bascule : release.json réécrit en dernier, par renommage atomique.
let pointer = cfg.data_bin.join(state::RELEASE_FILE);
let pointer_tmp = cfg.data_bin.join("release.json.tmp");
write_manifest(&pointer_tmp, &remote)?;
fs::rename(&pointer_tmp, &pointer).map_err(|e| format!("{}: {e}", pointer.display()))?;
prune(&cfg.data_bin, remote.build);
Ok(Outcome::Installed(remote.build))
}
/// Télécharge `<base>/<name>` dans `dst` via curl. `--speed-limit` coupe un
/// transfert figé (moins de 10 Ko/s pendant 20 s) bien avant `timeout`.
fn fetch(base: &str, name: &str, dst: &Path, timeout: Duration) -> Result<(), String> {
let url = format!("{}/{}", base.trim_end_matches('/'), name);
let out = Command::new("curl")
.args([
"-fsSL",
"--connect-timeout",
"10",
"--speed-limit",
"10240",
"--speed-time",
"20",
])
.arg("--max-time")
.arg(timeout.as_secs().max(1).to_string())
.arg("-o")
.arg(dst)
.arg(&url)
.output()
.map_err(|e| format!("curl introuvable : {e}"))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
return Err(format!("{name} : {}", err.trim()));
}
Ok(())
}
pub fn sha256_file(path: &Path) -> std::io::Result<String> {
let mut f = fs::File::open(path)?;
let mut h = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(hex::encode(h.finalize()))
}
fn write_manifest(path: &Path, m: &Manifest) -> Result<(), String> {
let text = serde_json::to_string(m).map_err(|e| e.to_string())?;
fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(unix)]
fn make_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
}
#[cfg(not(unix))]
fn make_executable(_: &Path) -> std::io::Result<()> {
Ok(())
}
/// Supprime les builds autres que `keep` (un seul build est gardé : celui en
/// service). Ne touche qu'aux dossiers au nom purement numérique.
fn prune(data_bin: &Path, keep: u64) {
let Ok(rd) = fs::read_dir(data_bin) else {
return;
};
for e in rd.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if let Ok(n) = name.parse::<u64>()
&& n != keep
&& e.path().is_dir()
{
let _ = fs::remove_dir_all(e.path());
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! État d'installation des binaires mis à jour, sous `<LOKI_HOME>/bin` :
//!
//! ```text
//! bin/release.json manifeste du build actif (écrit en DERNIER)
//! bin/<build>/loki binaire Go
//! bin/<build>/loki-rs binaire Rust
//! ```
//!
//! La bascule d'une version à l'autre se résume à réécrire `release.json` :
//! un arrêt brutal pendant une mise à jour laisse l'ancien build intact.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const RELEASE_FILE: &str = "release.json";
pub const GO_BIN: &str = "loki";
pub const RS_BIN: &str = "loki-rs";
/// Contenu de `manifest.json` (release) et de `release.json` (installé).
///
/// `build` est le numéro de run de la CI : il ne fait que croître, c'est lui
/// qui ordonne les versions (un commit ne s'ordonne pas). `abi` est l'ABI
/// d'image exigée par ces binaires.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub build: u64,
pub abi: u32,
#[serde(default)]
pub commit: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Install,
UpToDate,
AbiMismatch,
}
pub fn read_manifest(path: &Path) -> Option<Manifest> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
/// Build installé dans `data_bin` et utilisable avec cette image : même ABI,
/// plus récent que les binaires de l'image, et les deux binaires présents.
pub fn current(data_bin: &Path, image_build: u64, image_abi: u32) -> Option<(Manifest, PathBuf)> {
let m = read_manifest(&data_bin.join(RELEASE_FILE))?;
if m.abi != image_abi || m.build <= image_build {
return None;
}
let dir = data_bin.join(m.build.to_string());
if !dir.join(GO_BIN).is_file() || !dir.join(RS_BIN).is_file() {
return None;
}
Some((m, dir))
}
/// Dossier dont l'entrypoint doit prendre `loki` et `loki-rs` : toujours les
/// deux du même endroit, jamais un Go d'une version avec un Rust d'une autre.
pub fn select_dir(data_bin: &Path, image_dir: &Path, image_build: u64, image_abi: u32) -> PathBuf {
match current(data_bin, image_build, image_abi) {
Some((_, dir)) => dir,
None => image_dir.to_path_buf(),
}
}
/// Faut-il installer `remote`, sachant que le build en service est `current_build` ?
pub fn decide(remote: &Manifest, current_build: u64, image_abi: u32) -> Decision {
if remote.abi != image_abi {
Decision::AbiMismatch
} else if remote.build <= current_build {
Decision::UpToDate
} else {
Decision::Install
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(build: u64, abi: u32) -> Manifest {
Manifest {
build,
abi,
commit: "abc".into(),
}
}
fn install(data_bin: &Path, man: &Manifest, bins: &[&str]) {
let dir = data_bin.join(man.build.to_string());
std::fs::create_dir_all(&dir).unwrap();
for b in bins {
std::fs::write(dir.join(b), b"x").unwrap();
}
std::fs::write(
data_bin.join(RELEASE_FILE),
serde_json::to_string(man).unwrap(),
)
.unwrap();
}
#[test]
fn decision() {
assert_eq!(decide(&m(10, 2), 5, 1), Decision::AbiMismatch);
assert_eq!(decide(&m(5, 1), 5, 1), Decision::UpToDate);
assert_eq!(decide(&m(4, 1), 5, 1), Decision::UpToDate);
assert_eq!(decide(&m(6, 1), 5, 1), Decision::Install);
}
#[test]
fn selection_sans_installation() {
let t = tempfile::tempdir().unwrap();
let img = Path::new("/usr/local/bin");
assert_eq!(select_dir(t.path(), img, 3, 1), img);
}
#[test]
fn selection_installation_valide() {
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]);
let img = Path::new("/usr/local/bin");
assert_eq!(select_dir(t.path(), img, 3, 1), t.path().join("7"));
}
#[test]
fn selection_refuse_abi_differente() {
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 2), &[GO_BIN, RS_BIN]);
let img = Path::new("/img");
assert_eq!(select_dir(t.path(), img, 3, 1), img);
}
#[test]
fn selection_refuse_plus_ancien_que_l_image() {
// Image tirée à la main plus récente que /data/bin : l'image gagne.
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]);
let img = Path::new("/img");
assert_eq!(select_dir(t.path(), img, 7, 1), img);
assert_eq!(select_dir(t.path(), img, 9, 1), img);
}
#[test]
fn selection_refuse_binaire_manquant() {
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 1), &[GO_BIN]);
let img = Path::new("/img");
assert_eq!(select_dir(t.path(), img, 3, 1), img);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! Lecture du fichier SHA256SUMS publié avec chaque release (format
//! `sha256sum` : "<hex> <nom>", ou "<hex> *<nom>" en mode binaire).
use std::collections::HashMap;
/// Associe chaque nom de fichier à son empreinte SHA-256 en hexadécimal
/// minuscule. Les lignes vides ou mal formées sont ignorées : une entrée
/// manquante fera échouer la vérification du fichier concerné, pas la lecture.
pub fn parse(text: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for line in text.lines() {
let mut it = line.split_whitespace();
let (Some(hash), Some(name), None) = (it.next(), it.next(), it.next()) else {
continue;
};
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
out.insert(
name.trim_start_matches('*').to_string(),
hash.to_ascii_lowercase(),
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[test]
fn lit_les_deux_formats() {
let up = A.to_ascii_uppercase();
let text = format!("{A} loki-linux\n{up} *loki-rs-linux\n");
let m = parse(&text);
assert_eq!(m.get("loki-linux").map(String::as_str), Some(A));
assert_eq!(m.get("loki-rs-linux").map(String::as_str), Some(A));
}
#[test]
fn ignore_les_lignes_cassees() {
let text = format!("\nzz loki-linux\n{A}\n{A} a b\n{A} ok\n");
let m = parse(&text);
assert_eq!(m.len(), 1);
assert!(m.contains_key("ok"));
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Serveur HTTP minimal pour les tests : sert des fichiers en mémoire et
//! journalise les chemins demandés.
#![allow(dead_code)]
use bytes::Bytes;
use http_body_util::Full;
use hyper::{Request, Response, StatusCode, body::Incoming, service::service_fn};
use hyper_util::rt::TokioIo;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
pub struct FileServer {
pub addr: SocketAddr,
pub hits: Arc<Mutex<Vec<String>>>,
_rt: tokio::runtime::Runtime,
}
impl FileServer {
pub fn url(&self) -> String {
format!("http://{}/rolling", self.addr)
}
pub fn hits(&self) -> Vec<String> {
self.hits.lock().unwrap().clone()
}
}
pub fn serve_files(files: HashMap<String, Vec<u8>>) -> FileServer {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let files = Arc::new(files);
let hits = Arc::new(Mutex::new(Vec::new()));
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let addr = listener.local_addr().unwrap();
let h = hits.clone();
rt.spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let files = files.clone();
let h = h.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<Incoming>| {
let files = files.clone();
let h = h.clone();
async move {
let path = req.uri().path().trim_start_matches("/rolling/").to_string();
h.lock().unwrap().push(path.clone());
let resp = match files.get(&path) {
Some(b) => Response::new(Full::new(Bytes::from(b.clone()))),
None => {
let mut r = Response::new(Full::new(Bytes::from_static(b"404")));
*r.status_mut() = StatusCode::NOT_FOUND;
r
}
};
Ok::<_, std::convert::Infallible>(resp)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await;
});
}
});
FileServer {
addr,
hits,
_rt: rt,
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Lot 2 : UI statique, authentification et /api/ping servis par la façade,
//! devant un faux serveur Go qui imite son API interne.
use bytes::Bytes;
use flate2::read::GzDecoder;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode, service::service_fn};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use std::convert::Infallible;
use std::io::Read;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
const JETON: &str = "0123456789abcdef0123456789abcdef";
/// État du faux Go : clé de pilotage (`None` = base illisible → 503) et
/// journal des requêtes reçues (chemin + en-tête x-loki-internal).
#[derive(Default)]
struct FauxGo {
cle: Mutex<Option<String>>,
vus: Mutex<Vec<(String, Option<String>)>>,
}
async fn faux_go(etat: Arc<FauxGo>) -> SocketAddr {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
let etat = etat.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<Incoming>| {
let etat = etat.clone();
async move {
let jeton = req
.headers()
.get("x-loki-internal")
.map(|v| v.to_str().unwrap().to_string());
let path = req.uri().path().to_string();
etat.vus.lock().unwrap().push((path.clone(), jeton.clone()));
let (code, body) = if path == "/internal/v1/auth" {
if jeton.as_deref() != Some(JETON) {
(404, String::new())
} else {
match etat.cle.lock().unwrap().clone() {
Some(k) => {
(200, format!(r#"{{"web_key":"{k}","version":"0.15.0"}}"#))
}
None => (503, r#"{"error":"x"}"#.into()),
}
}
} else {
(200, format!("go:{path}"))
};
let mut r = Response::new(Full::new(Bytes::from(body)));
*r.status_mut() = StatusCode::from_u16(code).unwrap();
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
addr
}
struct Banc {
facade: SocketAddr,
etat: Arc<FauxGo>,
_stop: oneshot::Sender<()>,
}
async fn banc(cle: Option<&str>) -> Banc {
let etat = Arc::new(FauxGo::default());
*etat.cle.lock().unwrap() = cle.map(String::from);
let backend = faux_go(etat.clone()).await;
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let facade = l.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
let cfg = loki_rs::proxy::Config {
backend,
internal_token: JETON.into(),
host_env: loki_rs::auth::HostEnv {
hostname: None,
trusted: vec!["loki.exemple.fr".into()],
},
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
}));
Banc {
facade,
etat,
_stop: tx,
}
}
async fn get(
b: &Banc,
path: &str,
headers: &[(&str, &str)],
) -> (StatusCode, hyper::HeaderMap, Bytes) {
let mut req = Request::get(format!("http://{}{path}", b.facade));
for (k, v) in headers {
req = req.header(*k, *v);
}
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let resp = client
.request(req.body(Full::new(Bytes::new())).unwrap())
.await
.unwrap();
let (parts, body) = resp.into_parts();
(
parts.status,
parts.headers,
body.collect().await.unwrap().to_bytes(),
)
}
#[tokio::test]
async fn ping_sans_cle() {
let b = banc(Some("")).await;
let (code, h, body) = get(&b, "/api/ping", &[]).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(h["content-type"], "application/json");
assert_eq!(
&body[..],
b"{\"ok\":true,\"service\":\"loki\",\"version\":\"0.15.0\"}\n"
);
// Servi par Rust : le Go n'a vu que l'appel à son API interne, jeton compris.
let vus = b.etat.vus.lock().unwrap().clone();
assert_eq!(
vus,
vec![("/internal/v1/auth".to_string(), Some(JETON.to_string()))]
);
}
#[tokio::test]
async fn ping_avec_cle() {
let b = banc(Some("secret")).await;
let (code, h, body) = get(&b, "/api/ping", &[]).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
assert_eq!(h["www-authenticate"], r#"Bearer realm="loki""#);
assert_eq!(&body[..], "{\"error\":\"non autorisé\"}\n".as_bytes());
let (code, _, _) = get(&b, "/api/ping", &[("authorization", "Bearer faux")]).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _, _) = get(&b, "/api/ping", &[("authorization", "Bearer secret")]).await;
assert_eq!(code, StatusCode::OK);
}
#[tokio::test]
async fn ferme_si_la_base_est_illisible() {
let b = banc(None).await;
let (code, _, _) = get(&b, "/api/ping", &[]).await;
assert_eq!(code, StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn refuse_le_rebinding() {
let b = banc(Some("")).await;
let (code, _, body) = get(&b, "/api/ping", &[("host", "evil.example:8090")]).await;
assert_eq!(code, StatusCode::FORBIDDEN);
assert!(String::from_utf8_lossy(&body).contains("non autorisé sans clé"));
let (code, _, _) = get(&b, "/api/ping", &[("host", "loki.exemple.fr")]).await;
assert_eq!(code, StatusCode::OK);
}
#[tokio::test]
async fn api_interne_fermee_de_l_exterieur() {
let b = banc(Some("secret")).await;
let (code, _, body) = get(&b, "/internal/v1/auth", &[("x-loki-internal", JETON)]).await;
assert_eq!(code, StatusCode::NOT_FOUND);
assert!(!String::from_utf8_lossy(&body).contains("secret"));
assert!(
b.etat.vus.lock().unwrap().is_empty(),
"requête relayée au Go"
);
}
#[tokio::test]
async fn jeton_entrant_efface_avant_relais() {
let b = banc(Some("")).await;
let (code, _, body) = get(&b, "/api/status", &[("x-loki-internal", JETON)]).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(&body[..], b"go:/api/status");
let vus = b.etat.vus.lock().unwrap().clone();
assert_eq!(vus, vec![("/api/status".to_string(), None)]);
}
#[tokio::test]
async fn ui_servie_par_rust() {
let b = banc(Some("")).await;
let (code, h, body) = get(&b, "/", &[("accept-encoding", "gzip")]).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(h["content-encoding"], "gzip");
assert_eq!(h["cache-control"], "no-store, max-age=0");
let mut html = Vec::new();
GzDecoder::new(&body[..]).read_to_end(&mut html).unwrap();
assert_eq!(html, loki_rs::ui::index_bytes());
// Police inconnue : relayée au Go (qui rendra son 404).
let (_, _, body) = get(&b, "/fonts/absente.woff2", &[]).await;
assert_eq!(&body[..], b"go:/fonts/absente.woff2");
let vus = b.etat.vus.lock().unwrap().clone();
assert_eq!(
vus.len(),
1,
"seule la police inconnue doit atteindre le Go : {vus:?}"
);
}
+212
View File
@@ -0,0 +1,212 @@
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::BoxBody};
use hyper::body::{Frame, Incoming};
use hyper::{Request, Response, StatusCode, service::service_fn};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use std::convert::Infallible;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message;
type B = BoxBody<Bytes, Infallible>;
/// Backend factice : /echo renvoie les en-têtes reçus, /sse émet un événement,
/// attend 500 ms, puis un second ; /post renvoie le corps reçu.
async fn backend() -> SocketAddr {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (s, _) = l.accept().await.unwrap();
tokio::spawn(async move {
let svc = service_fn(|req: Request<Incoming>| async move {
let resp: Response<B> = match req.uri().path() {
"/echo" => {
let mut lines: Vec<String> = req
.headers()
.iter()
.map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap()))
.collect();
lines.push(format!("uri: {}", req.uri()));
Response::new(Full::new(Bytes::from(lines.join("\n"))).boxed())
}
"/post" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
Response::new(Full::new(body).boxed())
}
"/sse" => {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
tokio::spawn(async move {
tx.send(Bytes::from_static(b"data: un\n\n")).await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let _ = tx.send(Bytes::from_static(b"data: deux\n\n")).await;
});
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv()
.await
.map(|b| (Ok::<_, Infallible>(Frame::data(b)), rx))
});
let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream)));
r.headers_mut()
.insert("content-type", "text/event-stream".parse().unwrap());
r
}
_ => Response::new(Empty::new().boxed()),
};
Ok::<_, Infallible>(resp)
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
addr
}
async fn ws_backend() -> SocketAddr {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
tokio::spawn(async move {
let mut ws = tokio_tungstenite::accept_async(s).await.unwrap();
while let Some(Ok(m)) = ws.next().await {
if m.is_text() {
ws.send(m).await.unwrap();
}
}
});
}
});
addr
}
/// Lance la façade devant `backend`, rend son adresse.
async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
let cfg = loki_rs::proxy::Config {
backend,
internal_token: "jeton-de-test".into(),
host_env: Default::default(),
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
}));
(addr, tx)
}
fn client() -> Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
#[tokio::test]
async fn transmet_les_en_tetes_et_efface_x_loki_via() {
let (f, _stop) = facade(backend().await).await;
let req = Request::get(format!("http://{f}/echo?a=1"))
.header("host", "loki.exemple.fr")
.header("authorization", "Bearer secret")
.header("origin", "https://loki.exemple.fr")
.header("x-forwarded-proto", "https")
.header("sec-fetch-site", "same-origin")
.header("x-loki-via", "tunnel")
.body(Full::new(Bytes::new()))
.unwrap();
let resp = client().request(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
for want in [
"host: loki.exemple.fr",
"authorization: Bearer secret",
"origin: https://loki.exemple.fr",
"x-forwarded-proto: https",
"sec-fetch-site: same-origin",
"uri: /echo?a=1",
] {
assert!(
body.lines().any(|l| l == want),
"manque « {want} » dans :\n{body}"
);
}
assert!(
!body.contains("x-loki-via"),
"X-Loki-Via a traversé :\n{body}"
);
}
#[tokio::test]
async fn relaie_le_corps() {
let (f, _stop) = facade(backend().await).await;
let req = Request::post(format!("http://{f}/post"))
.body(Full::new(Bytes::from_static(b"{\"x\":1}")))
.unwrap();
let resp = client().request(req).await.unwrap();
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"{\"x\":1}");
}
#[tokio::test]
async fn sse_sans_tampon() {
let (f, _stop) = facade(backend().await).await;
let t0 = Instant::now();
let req = Request::get(format!("http://{f}/sse"))
.body(Full::new(Bytes::new()))
.unwrap();
let mut body = client().request(req).await.unwrap().into_body();
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
let elapsed = t0.elapsed();
assert_eq!(&first[..], b"data: un\n\n");
assert!(
elapsed < Duration::from_millis(300),
"premier événement reçu après {elapsed:?}"
);
let second = body.frame().await.unwrap().unwrap().into_data().unwrap();
assert_eq!(&second[..], b"data: deux\n\n");
}
#[tokio::test]
async fn websocket_relaye() {
let (f, _stop) = facade(ws_backend().await).await;
let (mut ws, resp) = tokio_tungstenite::connect_async(format!("ws://{f}/api/node/ws"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SWITCHING_PROTOCOLS);
ws.send(Message::text("bonjour")).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(got.into_text().unwrap().as_str(), "bonjour");
}
#[tokio::test]
async fn backend_absent_donne_502() {
// Port réservé puis libéré : personne n'y écoute.
let dead = TcpListener::bind("127.0.0.1:0")
.await
.unwrap()
.local_addr()
.unwrap();
let (f, _stop) = facade(dead).await;
// Route relayée au Go (« / » est servie par la façade elle-même).
let req = Request::get(format!("http://{f}/api/status"))
.body(Full::new(Bytes::new()))
.unwrap();
let resp = client().request(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
+148
View File
@@ -0,0 +1,148 @@
mod common;
use loki_rs::update::{self, Outcome, UpdateConfig, sha256_file, state};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
const GO: &[u8] = b"binaire go";
const RS: &[u8] = b"binaire rust";
fn sha(b: &[u8]) -> String {
let t = tempfile::NamedTempFile::new().unwrap();
std::fs::write(t.path(), b).unwrap();
sha256_file(t.path()).unwrap()
}
fn release(
build: u64,
abi: u32,
go: &[u8],
rs: &[u8],
sums_go: Option<&str>,
) -> HashMap<String, Vec<u8>> {
let sums = format!(
"{} loki-linux\n{} loki-rs-linux\n",
sums_go.map(str::to_string).unwrap_or_else(|| sha(go)),
sha(rs)
);
HashMap::from([
(
"manifest.json".into(),
format!(r#"{{"build":{build},"abi":{abi},"commit":"c{build}"}}"#).into_bytes(),
),
("SHA256SUMS".into(), sums.into_bytes()),
("loki-linux".into(), go.to_vec()),
("loki-rs-linux".into(), rs.to_vec()),
])
}
fn cfg(url: String, data_bin: &Path) -> UpdateConfig {
UpdateConfig {
enabled: true,
base_url: url,
data_bin: data_bin.to_path_buf(),
image_build: 3,
image_abi: 1,
small_timeout: Duration::from_secs(10),
big_timeout: Duration::from_secs(10),
}
}
#[test]
fn installe_puis_reconnait_a_jour() {
let srv = common::serve_files(release(7, 1, GO, RS, None));
let t = tempfile::tempdir().unwrap();
let c = cfg(srv.url(), t.path());
assert_eq!(update::run(&c), Outcome::Installed(7));
let dir = state::select_dir(t.path(), Path::new("/img"), 3, 1);
assert_eq!(dir, t.path().join("7"));
assert_eq!(std::fs::read(dir.join("loki")).unwrap(), GO);
assert_eq!(std::fs::read(dir.join("loki-rs")).unwrap(), RS);
assert!(!t.path().join(".tmp-download").exists());
assert_eq!(update::run(&c), Outcome::UpToDate(7));
}
#[test]
fn nouveau_build_remplace_et_nettoie_l_ancien() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 1, GO, RS, None));
assert_eq!(
update::run(&cfg(srv.url(), t.path())),
Outcome::Installed(7)
);
let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", None));
assert_eq!(
update::run(&cfg(srv2.url(), t.path())),
Outcome::Installed(9)
);
assert!(!t.path().join("7").exists());
assert_eq!(
state::select_dir(t.path(), Path::new("/img"), 3, 1),
t.path().join("9")
);
}
#[test]
fn empreinte_fausse_ne_change_rien() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 1, GO, RS, None));
assert_eq!(
update::run(&cfg(srv.url(), t.path())),
Outcome::Installed(7)
);
let bad = "0".repeat(64);
let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", Some(&bad)));
assert!(matches!(
update::run(&cfg(srv2.url(), t.path())),
Outcome::Failed(_)
));
assert_eq!(
state::select_dir(t.path(), Path::new("/img"), 3, 1),
t.path().join("7")
);
assert!(!t.path().join("9").exists());
assert!(!t.path().join(".tmp-download").exists());
}
#[test]
fn abi_differente_ne_telecharge_que_le_manifeste() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 2, GO, RS, None));
assert_eq!(
update::run(&cfg(srv.url(), t.path())),
Outcome::AbiMismatch {
remote_abi: 2,
image_abi: 1
}
);
assert_eq!(srv.hits(), vec!["manifest.json".to_string()]);
}
#[test]
fn image_plus_recente_que_la_release() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(3, 1, GO, RS, None));
assert_eq!(update::run(&cfg(srv.url(), t.path())), Outcome::UpToDate(3));
}
#[test]
fn serveur_absent() {
let t = tempfile::tempdir().unwrap();
let mut c = cfg("http://127.0.0.1:9/rolling".into(), t.path());
c.small_timeout = Duration::from_secs(3);
assert!(matches!(update::run(&c), Outcome::Failed(_)));
}
#[test]
fn desactive_sans_requete() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 1, GO, RS, None));
let mut c = cfg(srv.url(), t.path());
c.enabled = false;
assert_eq!(update::run(&c), Outcome::Disabled);
assert!(srv.hits().is_empty());
}