mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fa4488cc | ||
|
|
a99d988e70 |
No files matched your search
+6
-2
@@ -50,8 +50,12 @@ RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
|||||||
FROM rust:1-alpine AS rustbuild
|
FROM rust:1-alpine AS rustbuild
|
||||||
RUN apk add --no-cache musl-dev
|
RUN apk add --no-cache musl-dev
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY rust/ ./
|
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
|
||||||
RUN cargo build --release --locked && mkdir -p /out && cp target/release/loki-rs /out/loki-rs
|
# 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) ──────────
|
# ── É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
|
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ seule image.
|
|||||||
seule l'UI (8090) l'est.
|
seule l'UI (8090) l'est.
|
||||||
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
|
- **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
|
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
|
||||||
lui fait reprendre des routes. Plan et feuille de route :
|
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`.
|
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
|
||||||
|
|
||||||
## Démarrage rapide (Docker, GPU NVIDIA)
|
## Démarrage rapide (Docker, GPU NVIDIA)
|
||||||
|
|||||||
@@ -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é.
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,7 +96,9 @@ func cmdWeb(args []string) error {
|
|||||||
// clients tiers, en plein milieu d'une génération.
|
// clients tiers, en plein milieu d'une génération.
|
||||||
// gzipHandler : l'UI (~590 Ko) et les gros JSON partent compressés ; les flux
|
// 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).
|
// 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)
|
return srv.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+50
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -61,6 +67,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -103,6 +118,17 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
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]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -297,7 +323,9 @@ name = "loki-rs"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"getrandom",
|
||||||
"hex",
|
"hex",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -317,6 +345,16 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.4"
|
version = "1.2.4"
|
||||||
@@ -469,6 +507,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -663,6 +707,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
|
flate2 = "1.1.10"
|
||||||
|
getrandom = "0.4.3"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
hyper = { version = "1.12.0", features = ["http1", "server", "client"] }
|
hyper = { version = "1.12.0", features = ["http1", "server", "client"] }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,13 +41,14 @@ pub struct Supervisor {
|
|||||||
|
|
||||||
impl Supervisor {
|
impl Supervisor {
|
||||||
/// Lance `<go_bin> web <port>` en écoute locale et le maintient en vie.
|
/// Lance `<go_bin> web <port>` en écoute locale et le maintient en vie.
|
||||||
pub fn spawn(go_bin: PathBuf, port: u16) -> Supervisor {
|
/// `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 (tx, mut rx) = oneshot::channel::<Duration>();
|
||||||
let done = tokio::spawn(async move {
|
let done = tokio::spawn(async move {
|
||||||
let mut delay = Duration::ZERO;
|
let mut delay = Duration::ZERO;
|
||||||
loop {
|
loop {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let mut child = match launch(&go_bin, port) {
|
let mut child = match launch(&go_bin, port, &env) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -99,8 +100,9 @@ impl Supervisor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn launch(go_bin: &Path, port: u16) -> std::io::Result<Child> {
|
fn launch(go_bin: &Path, port: u16, env: &[(String, String)]) -> std::io::Result<Child> {
|
||||||
Command::new(go_bin)
|
Command::new(go_bin)
|
||||||
|
.envs(env.iter().map(|(k, v)| (k, v)))
|
||||||
.arg("web")
|
.arg("web")
|
||||||
.arg(port.to_string())
|
.arg(port.to_string())
|
||||||
.env("LOKI_WEB_BIND", "127.0.0.1")
|
.env("LOKI_WEB_BIND", "127.0.0.1")
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
//! loki-rs — façade Rust de Loki (lot 1 de la migration Go → Rust).
|
//! loki-rs — façade Rust de Loki (migration Go → Rust par lots).
|
||||||
//!
|
//!
|
||||||
//! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ;
|
//! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ;
|
||||||
//! - `child` : supervision du processus `loki web` (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.
|
//! - `update` : mise à jour des binaires au démarrage du conteneur.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
pub mod child;
|
pub mod child;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
pub mod ui;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
//! loki-rs version
|
//! loki-rs version
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use loki_rs::{child, proxy, update};
|
use loki_rs::{auth, child, proxy, update};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
@@ -89,7 +89,20 @@ fn serve() -> ExitCode {
|
|||||||
"[loki-rs] serveur Go : {} web {go_port} (local)",
|
"[loki-rs] serveur Go : {} web {go_port} (local)",
|
||||||
go_bin.display()
|
go_bin.display()
|
||||||
);
|
);
|
||||||
let sup = child::Supervisor::spawn(go_bin, go_port);
|
// 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;
|
wait_for(backend, Duration::from_secs(30)).await;
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
|
||||||
@@ -102,7 +115,12 @@ fn serve() -> ExitCode {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
|
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
|
||||||
let res = proxy::serve(listener, backend, shutdown_signal()).await;
|
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");
|
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||||
sup.shutdown(Duration::from_secs(10)).await;
|
sup.shutdown(Duration::from_secs(10)).await;
|
||||||
match res {
|
match res {
|
||||||
@@ -115,6 +133,12 @@ fn serve() -> ExitCode {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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` :
|
/// 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.
|
/// la façade ouvre quand même et répond 502 en attendant.
|
||||||
async fn wait_for(addr: SocketAddr, max: Duration) {
|
async fn wait_for(addr: SocketAddr, max: Duration) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Façade HTTP : reçoit tout le trafic public et le relaie au serveur Go
|
//! Façade HTTP : reçoit tout le trafic public. Les routes déjà réécrites en
|
||||||
//! (`loki web` sur 127.0.0.1). Les lots suivants de la migration y brancheront
|
//! Rust sont servies ici (lot 2 : UI statique, `/api/ping`) ; tout le reste
|
||||||
//! les routes réécrites en Rust ; au lot 1, tout est relayé.
|
//! est relayé au serveur Go (`loki web` sur 127.0.0.1).
|
||||||
//!
|
//!
|
||||||
//! Règles, héritées de `web_server.go` :
|
//! Règles, héritées de `web_server.go` :
|
||||||
//! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ;
|
//! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ;
|
||||||
@@ -27,6 +27,9 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use crate::auth::{self, HostEnv, INTERNAL_HEADER, InternalClient};
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
pub type Body = BoxBody<Bytes, hyper::Error>;
|
pub type Body = BoxBody<Bytes, hyper::Error>;
|
||||||
|
|
||||||
/// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`).
|
/// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`).
|
||||||
@@ -43,19 +46,35 @@ const HOP_BY_HOP: [&str; 8] = [
|
|||||||
"upgrade",
|
"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 {
|
struct Proxy {
|
||||||
client: Client<HttpConnector, Incoming>,
|
client: Client<HttpConnector, Incoming>,
|
||||||
backend: SocketAddr,
|
backend: SocketAddr,
|
||||||
|
internal: InternalClient,
|
||||||
|
host_env: HostEnv,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine.
|
/// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine.
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
backend: SocketAddr,
|
cfg: Config,
|
||||||
shutdown: impl Future<Output = ()>,
|
shutdown: impl Future<Output = ()>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||||
let proxy = Arc::new(Proxy { client, backend });
|
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);
|
tokio::pin!(shutdown);
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -91,9 +110,43 @@ pub async fn serve(
|
|||||||
|
|
||||||
impl Proxy {
|
impl Proxy {
|
||||||
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
|
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());
|
let upgrade = upgrade_protocol(req.headers());
|
||||||
strip_hop_by_hop(req.headers_mut());
|
strip_hop_by_hop(req.headers_mut());
|
||||||
req.headers_mut().remove(VIA_TUNNEL);
|
|
||||||
let client_upgrade = upgrade.as_ref().map(|proto| {
|
let client_upgrade = upgrade.as_ref().map(|proto| {
|
||||||
let h = req.headers_mut();
|
let h = req.headers_mut();
|
||||||
h.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
|
h.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,7 +91,12 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
|||||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = l.local_addr().unwrap();
|
let addr = l.local_addr().unwrap();
|
||||||
let (tx, rx) = oneshot::channel::<()>();
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
tokio::spawn(loki_rs::proxy::serve(l, backend, async move {
|
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;
|
let _ = rx.await;
|
||||||
}));
|
}));
|
||||||
(addr, tx)
|
(addr, tx)
|
||||||
@@ -198,7 +203,8 @@ async fn backend_absent_donne_502() {
|
|||||||
.local_addr()
|
.local_addr()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let (f, _stop) = facade(dead).await;
|
let (f, _stop) = facade(dead).await;
|
||||||
let req = Request::get(format!("http://{f}/"))
|
// 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()))
|
.body(Full::new(Bytes::new()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resp = client().request(req).await.unwrap();
|
let resp = client().request(req).await.unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user