Files
Loki/internal/loki/web_internal.go
T
Claude a4f8bd3ec4 Lot 17 : interrupteurs système tenus par la façade (LOKI_SYSTEM=rust)
La façade sert /api/internet (moteur web, serveur Crawl4AI et sa clé
jamais rendue, joignabilité avec son cache de 30 s), /api/computer
(interrupteur du navigateur, binaire trouvé, vision), /api/apikey (clé de
complétion, repli sur config.env, adresse /v1 à coller dans un client
tiers) et /api/network (écoute du moteur) quand LOKI_SYSTEM=rust
(expérimental ; le Go reste le défaut).

Restent au Go derrière son API interne (facade_system.go) : le cache de
joignabilité de ses outils web (/internal/v1/web/reach/reset), le
Chromium des outils browser_* (/internal/v1/cdp/shutdown) et le
redémarrage qui applique une clé neuve quand la façade ne pilote pas le
moteur (/internal/v1/service/action). Le relais du tunnel garde l'hôte
et X-Forwarded-Proto du client.

Correction en passant : maskAPIKey paniquait sur une clé d'un octet, et
/api/apikey tombait à chaque lecture.

Parité : parity_system_test.go tire du Go 61 appels de route avec la
base et les sondes reçues par trois faux Crawl4AI après chacun, et huit
dispositions du dossier de Playwright, rejoués par tests/system.rs ; ce
qui reste au Go vérifié de bout en bout ; TestFacadeSysteme,
TestFacadeRelaisGardeHote et TestMaskAPIKeyCourte côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:51:58 +00:00

242 lines
8.1 KiB
Go

package loki
import (
"crypto/subtle"
"io"
"net/http"
"os"
"strings"
bolt "go.etcd.io/bbolt"
)
// 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)
case "/internal/v1/store":
handleInternalStore(w, r)
case "/internal/v1/store/all":
handleInternalStoreAll(w, r)
case "/internal/v1/oai/route":
handleInternalOAIRoute(w, r)
case "/internal/v1/oai/begin":
handleInternalOAIBegin(w, r)
case "/internal/v1/oai/end":
handleInternalOAIEnd(w, r)
case "/internal/v1/tool/exec":
handleInternalToolExec(w, r)
case "/internal/v1/tool/context":
handleInternalToolContext(w, r)
case "/internal/v1/tool/tracker":
handleInternalToolTracker(w, r)
case "/internal/v1/tool/wrote":
handleInternalToolWrote(w, r)
case "/internal/v1/crypt/seal":
handleInternalCrypt(w, r, true)
case "/internal/v1/crypt/open":
handleInternalCrypt(w, r, false)
case "/internal/v1/engine/lease":
handleInternalLease(w, r)
case "/internal/v1/engine/lease/renew":
handleInternalLeaseRenew(w, r)
case "/internal/v1/engine/lease/release":
handleInternalLeaseRelease(w, r)
case "/internal/v1/engine/stop":
handleInternalEngineStop(w, r)
case "/internal/v1/recall/archive":
handleInternalRecallArchive(w, r)
case "/internal/v1/turn/start":
handleInternalTurnStart(w, r)
case "/internal/v1/turn/view":
handleInternalTurnView(w, r)
case "/internal/v1/turn/done":
handleInternalTurnDone(w, r)
case "/internal/v1/turn/verifier":
handleInternalTurnVerifier(w, r)
case "/internal/v1/project/activate":
handleInternalProjectActivate(w, r)
case "/internal/v1/chat/holder":
handleInternalChatHolder(w, r)
case "/internal/v1/conv/active":
handleInternalConvActive(w, r)
case "/internal/v1/conv/dropped":
handleInternalConvDropped(w, r)
case "/internal/v1/conv/workspace":
handleInternalConvWorkspace(w, r)
case "/internal/v1/preset/apply":
handleInternalPresetApply(w, r)
case "/internal/v1/tune/guard":
handleInternalTuneGuard(w, r)
case "/internal/v1/service/prestart":
handleInternalServicePrestart(w, r)
case "/internal/v1/chat/generating":
handleInternalChatGenerating(w, r)
case "/internal/v1/dictate/stop":
handleInternalDictateStop(w, r)
case "/internal/v1/web/reach/reset":
handleInternalReachReset(w, r)
case "/internal/v1/cdp/shutdown":
handleInternalCDPShutdown(w, r)
case "/internal/v1/service/action":
handleInternalServiceAction(w, r)
default:
http.NotFound(w, r)
}
})
}
// --- Store ----------------------------------------------------------------
//
// Accès générique à la base pour les routes déjà portées en Rust (lot 4) : le
// Go reste l'unique propriétaire du fichier bbolt jusqu'au lot qui le
// migrera ; Rust lit et écrit À TRAVERS lui. Les écritures passent par
// putBytes/update, donc invalident le cache de ce process comme une écriture
// locale.
//
// GET /internal/v1/store?bucket=B&key=K 200 + valeur brute | 404 absente
// PUT /internal/v1/store?bucket=B&key=K corps = valeur → 204
// DELETE /internal/v1/store?bucket=B&key=K → 204
// GET /internal/v1/store/all?bucket=B 200 + {"clé":"valeur",…}
//
// Base illisible : 503 — l'appelant distingue « rien » de « pas pu lire »,
// comme getBytesErr.
// internalStoreMaxPut borne une écriture : la plus grosse valeur légitime (le
// journal d'une discussion) reste très en dessous.
const internalStoreMaxPut = 64 << 20
func handleInternalStore(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
bucket, key := q.Get("bucket"), q.Get("key")
if bucket == "" || key == "" {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "bucket et key requis"})
return
}
switch r.Method {
case http.MethodGet:
var val []byte
var found bool
err := view(bucket, func(b *bolt.Bucket) error {
if v := b.Get([]byte(key)); v != nil {
val, found = append([]byte(nil), v...), true
}
return nil
})
if err != nil {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(val)
case http.MethodPut:
val, err := io.ReadAll(io.LimitReader(r.Body, internalStoreMaxPut+1))
if err != nil || len(val) > internalStoreMaxPut {
sendJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "valeur trop grande ou illisible"})
return
}
if err := putBytes(bucket, key, val); err != nil {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
err := update(bucket, func(b *bolt.Bucket) error { return b.Delete([]byte(key)) })
if err != nil {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "GET, PUT, DELETE")
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "méthode non permise"})
}
}
func handleInternalStoreAll(w http.ResponseWriter, r *http.Request) {
bucket := r.URL.Query().Get("bucket")
if r.Method != http.MethodGet || bucket == "" {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "GET ?bucket= attendu"})
return
}
m := map[string]string{}
err := view(bucket, func(b *bolt.Bucket) error {
return b.ForEach(func(k, v []byte) error {
m[string(k)] = string(v)
return nil
})
})
if err != nil {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
sendJSON(w, http.StatusOK, m)
}
// 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})
}