mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur
- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la reprise au démarrage déchiffre aussi les conversations au lieu de retirer la clé après les seules pages. Propre à Loki : les images de conversation (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés. - Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du conteneur ne le prend plus pour un plantage. - Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s en pleine génération). - Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler. - Paramètres → Configuration : commande exacte du moteur copiable (clé API masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a9a5a1712
commit
127013b32b
16 files changed
+479
-34
No files matched your search
@@ -0,0 +1,43 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
|
||||
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
|
||||
// relançait en boucle (amont #95).
|
||||
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdServe(nil); err != nil {
|
||||
t.Fatalf("loki serve sur un preset externe : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
|
||||
// en invitant à réinstaller llama.cpp.
|
||||
func TestPreflightAcceptsExternalPreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
|
||||
func TestPreflightStrataWithoutConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightEngine()
|
||||
if err == nil {
|
||||
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
|
||||
}
|
||||
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
|
||||
t.Fatalf("message attendu sur %s : %v", want, err)
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,12 @@ func cmdServe(args []string) error {
|
||||
}
|
||||
cfg = c
|
||||
}
|
||||
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
|
||||
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
|
||||
if isExternalConfig(cfg) {
|
||||
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
|
||||
return nil
|
||||
}
|
||||
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
|
||||
// propre serveur. L'optimiseur ne règle que llama-server.
|
||||
if isStrataConfig(cfg) {
|
||||
@@ -471,6 +477,10 @@ func cmdServe(args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), port)
|
||||
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
|
||||
if trialDir == "" {
|
||||
recordEngineCmdline(env, llmArgs)
|
||||
}
|
||||
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
||||
|
||||
@@ -1036,6 +1036,7 @@ func serveStrata(cfg map[string]string) error {
|
||||
_ = os.Chdir(srcDir)
|
||||
fmt.Fprintf(os.Stderr, "%s (paquet AJEAN MoE %s) config=%s port=%s gpu=%s\n",
|
||||
strataServeMarker, strataVersion, filepath.Base(out), port, os.Getenv("CUDA_VISIBLE_DEVICES"))
|
||||
recordEngineCmdline(map[string]string{"CUDA_VISIBLE_DEVICES": os.Getenv("CUDA_VISIBLE_DEVICES")}, args)
|
||||
return execServer(py, args)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package loki
|
||||
|
||||
// engine_cmdline.go — ce que l'interface montre du dernier lancement du moteur
|
||||
// (repris d'AJEAN, #108 et #43) :
|
||||
//
|
||||
// /api/engine/cmdline la commande exacte lancée par `loki serve` (variables
|
||||
// d'environnement posées par Loki comprises), copiable
|
||||
// pour la vérifier ou la partager ; clé API masquée
|
||||
// /api/model/layers le nombre de couches du modèle, lu dans l'en-tête
|
||||
// GGUF : la valeur de NGL qui met tout sur le GPU
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// engineCmdlineKey : dernière commande du moteur (bkState), lue par l'UI.
|
||||
const engineCmdlineKey = "engine_cmdline"
|
||||
|
||||
// engineCmdline met les arguments sous forme copiable dans un terminal :
|
||||
// variables d'environnement en tête (KEY=VAL, triées), guillemets autour de ce
|
||||
// qui contient un espace, clé API masquée.
|
||||
func engineCmdline(env map[string]string, args []string) string {
|
||||
quote := func(a string) string {
|
||||
if a == "" || strings.ContainsAny(a, " \t\"'") {
|
||||
return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
|
||||
}
|
||||
return a
|
||||
}
|
||||
var out []string
|
||||
keys := make([]string, 0, len(env))
|
||||
for k := range env {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+quote(env[k]))
|
||||
}
|
||||
for i, a := range args {
|
||||
if i > 0 && (args[i-1] == "--api-key" || args[i-1] == "--api-key-file") {
|
||||
a = "<clé masquée>"
|
||||
}
|
||||
out = append(out, quote(a))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// recordEngineCmdline garde la commande du lancement en cours. Jamais pour un
|
||||
// essai de l'optimiseur : il écraserait celle du vrai moteur.
|
||||
func recordEngineCmdline(env map[string]string, args []string) {
|
||||
_ = putBytes(bkState, engineCmdlineKey, []byte(engineCmdline(env, args)))
|
||||
}
|
||||
|
||||
// handleEngineCmdline (GET) : la commande du dernier lancement du moteur local.
|
||||
func handleEngineCmdline(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "cmdline": string(getBytes(bkState, engineCmdlineKey))})
|
||||
}
|
||||
|
||||
// handleModelLayers (GET ?model=…, sinon MODEL de la config) : nombre de blocs
|
||||
// du modèle. llama.cpp en offloade block_count + 1 (la couche de sortie en
|
||||
// plus) : c'est la valeur de -ngl qui met tout le modèle sur le GPU.
|
||||
func handleModelLayers(w http.ResponseWriter, r *http.Request) {
|
||||
model := strings.TrimSpace(r.URL.Query().Get("model"))
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
}
|
||||
if model == "" {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": "aucun modèle"})
|
||||
return
|
||||
}
|
||||
path, err := resolveServeModelPath(model)
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
info, err := ggufMeta(path)
|
||||
if err != nil || info.BlockCount <= 0 {
|
||||
msg := "nombre de couches absent de l'en-tête"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "layers": info.BlockCount, "ngl_max": info.BlockCount + 1})
|
||||
}
|
||||
+123
-17
@@ -259,52 +259,158 @@ func DisableMemEncryption() error {
|
||||
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
|
||||
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
|
||||
// levé, donc DisableMemEncryption peut être relancé).
|
||||
if err := decryptAllPages(); err != nil {
|
||||
return finishDecryption()
|
||||
}
|
||||
|
||||
// finishDecryption : fichiers (pages, images des conversations), puis
|
||||
// conversations, puis retrait de la clé, dans cet ordre et seulement si tout ce
|
||||
// qui précède a réussi. Sûr à rejouer. Partagé avec la reprise au démarrage, qui
|
||||
// retirait la clé après les seules pages : les conversations restaient
|
||||
// chiffrées sans plus aucun moyen de les rouvrir (amont cac0cda).
|
||||
func finishDecryption() error {
|
||||
bad, err := decryptAllPagesSkipping()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decryptChatStores(); err != nil {
|
||||
badKV, err := decryptChatStoresSkipping()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
|
||||
// retirer le keyvault et purger la DEK.
|
||||
bad = append(bad, badKV...)
|
||||
if len(bad) > 0 {
|
||||
// Valeurs illisibles avec la clé actuelle : déjà inaccessibles avant ce
|
||||
// déchiffrement. On les met en quarantaine (toujours chiffrées) avec une
|
||||
// copie du keyvault, pour pouvoir encore les rouvrir plus tard, puis on
|
||||
// les retire de la base pour que le reste fonctionne en clair.
|
||||
if err := quarantineUnreadable(bad); err != nil {
|
||||
return fmt.Errorf("mise en quarantaine des valeurs illisibles impossible, déchiffrement interrompu (rien n'est perdu, il peut être relancé) : %w", err)
|
||||
}
|
||||
for _, u := range bad {
|
||||
if u.Bucket != "fichier" { // un fichier reste en place, sa copie est en quarantaine
|
||||
_ = putBytes(u.Bucket, u.Key, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tout est en clair et vérifié : on peut baisser le drapeau, retirer le
|
||||
// keyvault et purger la DEK.
|
||||
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
if n := scrubEncryptedResidue(); n > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] %d copie(s) .bak chiffrée(s), illisibles sans la clé, retirée(s)\n", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
|
||||
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
|
||||
func decryptAllPages() error {
|
||||
for _, p := range allMemPageFiles() {
|
||||
// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de
|
||||
// la mémoire (tous projets) et des images de conversation, en vérifiant chacun.
|
||||
// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel
|
||||
// quel et renvoyé au lieu de tout arrêter.
|
||||
func decryptAllPagesSkipping() ([]unreadableValue, error) {
|
||||
var bad []unreadableValue
|
||||
for _, p := range allMemFilesForDecrypt() {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture %s : %w", p, err)
|
||||
return bad, fmt.Errorf("lecture %s : %w", p, err)
|
||||
}
|
||||
if !looksEncrypted(raw) {
|
||||
continue // déjà en clair
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
|
||||
bad = append(bad, unreadableValue{Bucket: "fichier", Key: p, Blob: raw, Error: err.Error()})
|
||||
continue // on n'écrase rien
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
|
||||
return fmt.Errorf("écriture claire %s : %w", p, err)
|
||||
return bad, fmt.Errorf("écriture claire %s : %w", p, err)
|
||||
}
|
||||
back, err := os.ReadFile(p)
|
||||
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
|
||||
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
|
||||
return bad, fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
|
||||
}
|
||||
}
|
||||
return bad, nil
|
||||
}
|
||||
|
||||
// allMemFilesForDecrypt : TOUS les fichiers de la mémoire, sous-dossiers compris,
|
||||
// et les images de conversation (chatimg/, chiffrées comme le fil), sauf les
|
||||
// copies .bak et les écritures en cours (.tmp). Ne déchiffrer que les pages .md
|
||||
// laissait le reste chiffré sans plus aucune clé une fois le keyvault retiré.
|
||||
func allMemFilesForDecrypt() []string {
|
||||
var out []string
|
||||
for _, root := range []string{projectsRoot(), chatImgDir()} {
|
||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || strings.HasSuffix(d.Name(), ".bak") || strings.HasSuffix(d.Name(), ".tmp") {
|
||||
return nil
|
||||
}
|
||||
out = append(out, p)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quarantineUnreadable écrit les valeurs indéchiffrables, encore chiffrées, dans
|
||||
// un dossier daté de LOKI_HOME, avec une copie du keyvault à côté : elles
|
||||
// restent récupérables si l'ancienne clé est retrouvée.
|
||||
func quarantineUnreadable(bad []unreadableValue) error {
|
||||
dir := filepath.Join(LokiHome(), "chiffre-illisible-"+time.Now().Format("20060102-150405"))
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(bad, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileAtomic(filepath.Join(dir, "valeurs.json"), raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range []string{vaultPathPrimary(), vaultPathBackup()} {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(filepath.Join(dir, filepath.Base(p)), b, 0o600)
|
||||
}
|
||||
}
|
||||
if b := getBytes(bkState, vaultDBKey); len(b) > 0 {
|
||||
_ = memWriteFileAtomic(filepath.Join(dir, "keyvault-base.json"), b, 0o600)
|
||||
}
|
||||
for _, u := range bad {
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] illisible avec la clé actuelle, mis en quarantaine : %s / %s (%s)\n", u.Bucket, u.Key, u.Error)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] %d valeur(s) en quarantaine dans %s\n", len(bad), dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scrubEncryptedResidue retire les .bak encore CHIFFRÉS de la mémoire une fois
|
||||
// le chiffrement désactivé : la clé est partie avec le keyvault, ils ne pourront
|
||||
// plus jamais être lus, et ce sont des copies de pages qui viennent d'être
|
||||
// remises en clair. Pendant symétrique de scrubPlaintextResidue. Les valeurs en
|
||||
// quarantaine et les snapshots vivent hors de memory/ : jamais touchés. Rien
|
||||
// n'est fait tant que le chiffrement est actif.
|
||||
func scrubEncryptedResidue() int {
|
||||
if memEncActive() {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, root := range []string{projectsRoot(), chatImgDir()} {
|
||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
|
||||
return nil
|
||||
}
|
||||
if b, err := os.ReadFile(p); err == nil && looksEncrypted(b) && os.Remove(p) == nil {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
|
||||
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
|
||||
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
|
||||
@@ -328,12 +434,12 @@ func resumeMemMigration() {
|
||||
// encore levé, on termine proprement (écrit le clair restant), puis on
|
||||
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
|
||||
// et l'écran de santé signalera le mélange.
|
||||
// Même chemin complet que DisableMemEncryption : la reprise retirait la clé
|
||||
// après les seules pages, en laissant les conversations chiffrées sans
|
||||
// plus aucun moyen de les rouvrir.
|
||||
if memEncActive() && memUnlocked() {
|
||||
if err := decryptAllPages(); err == nil {
|
||||
_ = SetConfigKey("MEM_ENCRYPTED", "")
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
if err := finishDecryption(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -261,3 +262,84 @@ func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
|
||||
t.Fatalf("pointeur chiffré non réparé : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images
|
||||
// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault
|
||||
// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en
|
||||
// quarantaine, et la désactivation aboutit.
|
||||
func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img := []byte("\x89PNG octets d'image de test")
|
||||
ref, err := storeChatImage(img, "image/png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))
|
||||
if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) {
|
||||
t.Fatal("l'image devrait être chiffrée")
|
||||
}
|
||||
other := make([]byte, 32)
|
||||
other[0] = 7
|
||||
alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := putBytes(bkChat, "vieille", alien); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatalf("la désactivation aurait dû aboutir : %v", err)
|
||||
}
|
||||
if memEncActive() {
|
||||
t.Fatal("le chiffrement devrait être désactivé")
|
||||
}
|
||||
if b, _ := os.ReadFile(imgPath); string(b) != string(img) {
|
||||
t.Fatalf("image pas déchiffrée : %q", b)
|
||||
}
|
||||
if v := getBytes(bkChat, "vieille"); len(v) != 0 {
|
||||
t.Fatal("la valeur illisible aurait dû quitter la base")
|
||||
}
|
||||
q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json"))
|
||||
if len(q) != 1 {
|
||||
t.Fatalf("quarantaine attendue, trouvée : %v", q)
|
||||
}
|
||||
if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") {
|
||||
t.Fatal("la quarantaine doit contenir la valeur illisible")
|
||||
}
|
||||
if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 {
|
||||
t.Fatalf(".bak chiffrés restés derrière : %v", baks)
|
||||
}
|
||||
}
|
||||
|
||||
// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé
|
||||
// après les seules pages, et les conversations restaient chiffrées pour toujours.
|
||||
func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := putBytes(bkChat, "conv:x", enc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeMigrationJournal("decrypt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resumeMemMigration()
|
||||
if memEncActive() {
|
||||
t.Fatal("la reprise aurait dû terminer le déchiffrement")
|
||||
}
|
||||
if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` {
|
||||
t.Fatalf("conversation restée chiffrée après la reprise : %q", v)
|
||||
}
|
||||
}
|
||||
@@ -188,18 +188,41 @@ func reencryptChatStores() error {
|
||||
|
||||
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
|
||||
func decryptChatStores() error {
|
||||
_, err := decryptChatStoresSkipping()
|
||||
return err
|
||||
}
|
||||
|
||||
// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé
|
||||
// actuelle (écrite avec une autre clé, ou abîmée). Gardée telle quelle.
|
||||
type unreadableValue struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
Blob []byte `json:"blob"` // base64 en JSON
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// decryptChatStoresSkipping déchiffre tout ce qui peut l'être et RENVOIE ce qui
|
||||
// ne le peut pas, au lieu de s'arrêter au premier échec : une seule valeur
|
||||
// illisible bloquait toute la désactivation du chiffrement, sans dire laquelle
|
||||
// (amont, 2026-10-03).
|
||||
func decryptChatStoresSkipping() ([]unreadableValue, error) {
|
||||
persistQ.flush() // même raison que reencryptChatStores
|
||||
var bad []unreadableValue
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := decryptBucket(b); err != nil {
|
||||
return err
|
||||
u, err := decryptBucket(b)
|
||||
if err != nil {
|
||||
return bad, err
|
||||
}
|
||||
bad = append(bad, u...)
|
||||
}
|
||||
return nil
|
||||
return bad, nil
|
||||
}
|
||||
|
||||
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
||||
// la DEK en RAM. Sûr à rejouer.
|
||||
func decryptBucket(bucket string) error {
|
||||
// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle
|
||||
// quelle et renvoyée ; seule une erreur d'écriture est fatale.
|
||||
func decryptBucket(bucket string) ([]unreadableValue, error) {
|
||||
var bad []unreadableValue
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if !looksEncrypted(raw) {
|
||||
@@ -207,11 +230,12 @@ func decryptBucket(bucket string) error {
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
bad = append(bad, unreadableValue{Bucket: bucket, Key: k, Blob: raw, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
if err := putBytes(bucket, k, plain); err != nil {
|
||||
return err
|
||||
return bad, err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return bad, nil
|
||||
}
|
||||
@@ -32,6 +32,9 @@ WorkingDirectory=%s
|
||||
ExecStart=%s
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# llama-server ignore SIGTERM en pleine génération : sans borne, une bascule de
|
||||
# preset restait bloquée 90 s (défaut systemd) avant le SIGKILL.
|
||||
TimeoutStopSec=5
|
||||
# Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas
|
||||
# en RAM) : relancer n'y changerait rien.
|
||||
RestartPreventExitStatus=78
|
||||
|
||||
@@ -41,6 +41,12 @@ func serviceAction(action string) error {
|
||||
// visible que dans le journal.
|
||||
func preflightEngine() error {
|
||||
cfg := ReadConfig()
|
||||
// Preset externe : aucun llama-server local à lancer. Exiger BIN/MODEL
|
||||
// refusait `loki start` et `loki restart` avec un message invitant à
|
||||
// réinstaller llama.cpp (amont #95) ; cmdServe sort déjà proprement.
|
||||
if isExternalConfig(cfg) {
|
||||
return nil
|
||||
}
|
||||
// Strata n'a ni BIN ni MODEL : son installeur a tout rangé (backend_strata.go).
|
||||
if isStrataConfig(cfg) {
|
||||
return strataPreflight(cfg)
|
||||
|
||||
@@ -65,6 +65,12 @@ func userSvcAction(action string) error {
|
||||
}
|
||||
|
||||
func userSvcStart() error {
|
||||
// Preset externe : `loki serve` sortirait aussitôt (rien à lancer), et
|
||||
// userCheckStarted le prendrait pour un plantage.
|
||||
if isExternalConfig(ReadConfig()) {
|
||||
fmt.Printf("%s preset externe actif : pas de moteur local à démarrer\n", dim("[info]"))
|
||||
return nil
|
||||
}
|
||||
if pid := readServicePID(); pid > 0 && processAlive(pid) {
|
||||
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
|
||||
return nil
|
||||
|
||||
@@ -3545,7 +3545,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
|
||||
@@ -4478,7 +4478,7 @@ async function loadCfg(){
|
||||
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
document.getElementById('cfg').innerHTML = rows.join('');
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
return;
|
||||
}
|
||||
if(c.BIN){
|
||||
@@ -4502,14 +4502,28 @@ async function loadCfg(){
|
||||
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
|
||||
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
||||
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
|
||||
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
|
||||
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
|
||||
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
|
||||
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
||||
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
|
||||
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
|
||||
rows.push(row(k, v));
|
||||
});
|
||||
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
||||
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
||||
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
||||
document.getElementById('cfg').innerHTML = rows.join('');
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
}
|
||||
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
|
||||
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
|
||||
let ENGINE_CMDLINE='';
|
||||
async function engineCmdlineRow(){
|
||||
const cl=await jget('/api/engine/cmdline').catch(()=>null);
|
||||
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
|
||||
if(!ENGINE_CMDLINE) return '';
|
||||
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
|
||||
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
|
||||
}
|
||||
// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
|
||||
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
|
||||
@@ -5740,6 +5754,20 @@ function onPickModel(){
|
||||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||||
}
|
||||
toast('MODEL='+val);
|
||||
showNglMax(val);
|
||||
}
|
||||
|
||||
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
|
||||
// affiché à côté du champ NGL (placeholder, « / N » et survol).
|
||||
async function showNglMax(model){
|
||||
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
|
||||
e.placeholder='999'; e.title=''; if(u) u.textContent='';
|
||||
if(!model) return;
|
||||
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
|
||||
if(!ly || !ly.ok) return;
|
||||
e.placeholder=String(ly.ngl_max);
|
||||
if(u) u.textContent='/ '+ly.ngl_max;
|
||||
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
|
||||
}
|
||||
|
||||
// --- Vision (projecteur multimodal --mmproj) --------------------------------
|
||||
@@ -6154,6 +6182,7 @@ function populateSettings(){
|
||||
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
|
||||
set('s-ctx', cfgReadKey('CTX'));
|
||||
set('s-ngl', cfgReadKey('NGL'));
|
||||
showNglMax(cfgReadKey('MODEL'));
|
||||
set('s-threads', cfgReadKey('THREADS'));
|
||||
set('s-batch', cfgReadKey('BATCH'));
|
||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||
|
||||
@@ -1288,7 +1288,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
|
||||
|
||||
@@ -333,7 +333,7 @@ async function loadCfg(){
|
||||
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
document.getElementById('cfg').innerHTML = rows.join('');
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
return;
|
||||
}
|
||||
if(c.BIN){
|
||||
@@ -357,12 +357,26 @@ async function loadCfg(){
|
||||
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
|
||||
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
||||
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
|
||||
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
|
||||
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
|
||||
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
|
||||
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
||||
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
|
||||
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
|
||||
rows.push(row(k, v));
|
||||
});
|
||||
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
||||
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
||||
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
||||
document.getElementById('cfg').innerHTML = rows.join('');
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
}
|
||||
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
|
||||
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
|
||||
let ENGINE_CMDLINE='';
|
||||
async function engineCmdlineRow(){
|
||||
const cl=await jget('/api/engine/cmdline').catch(()=>null);
|
||||
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
|
||||
if(!ENGINE_CMDLINE) return '';
|
||||
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
|
||||
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
|
||||
}
|
||||
@@ -599,6 +599,20 @@ function onPickModel(){
|
||||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||||
}
|
||||
toast('MODEL='+val);
|
||||
showNglMax(val);
|
||||
}
|
||||
|
||||
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
|
||||
// affiché à côté du champ NGL (placeholder, « / N » et survol).
|
||||
async function showNglMax(model){
|
||||
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
|
||||
e.placeholder='999'; e.title=''; if(u) u.textContent='';
|
||||
if(!model) return;
|
||||
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
|
||||
if(!ly || !ly.ok) return;
|
||||
e.placeholder=String(ly.ngl_max);
|
||||
if(u) u.textContent='/ '+ly.ngl_max;
|
||||
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
|
||||
}
|
||||
|
||||
// --- Vision (projecteur multimodal --mmproj) --------------------------------
|
||||
@@ -1013,6 +1027,7 @@ function populateSettings(){
|
||||
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
|
||||
set('s-ctx', cfgReadKey('CTX'));
|
||||
set('s-ngl', cfgReadKey('NGL'));
|
||||
showNglMax(cfgReadKey('MODEL'));
|
||||
set('s-threads', cfgReadKey('THREADS'));
|
||||
set('s-batch', cfgReadKey('BATCH'));
|
||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||
|
||||
@@ -53,7 +53,9 @@ func capsFromBody(body chatReq) Caps {
|
||||
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
||||
mu := &sync.Mutex{}
|
||||
done := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
|
||||
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
|
||||
t := time.NewTicker(4 * time.Second)
|
||||
@@ -64,6 +66,15 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
|
||||
return
|
||||
case <-t.C:
|
||||
mu.Lock()
|
||||
// Arrêt demandé pendant l'attente du verrou : le handler peut déjà
|
||||
// être revenu, et écrire dans son ResponseWriter fait planter le
|
||||
// process (amont #105).
|
||||
select {
|
||||
case <-done:
|
||||
mu.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
_, err := w.Write([]byte(": ping\n\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@@ -75,7 +86,13 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
|
||||
}
|
||||
}
|
||||
}()
|
||||
return mu, func() { close(done) }
|
||||
var once sync.Once
|
||||
// stop attend la fin de la goroutine : après son retour, plus aucune
|
||||
// écriture ne peut partir vers w.
|
||||
return mu, func() {
|
||||
once.Do(func() { close(done) })
|
||||
<-exited
|
||||
}
|
||||
}
|
||||
|
||||
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
|
||||
|
||||
@@ -220,6 +220,8 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
|
||||
api("/api/ram", handleRam)
|
||||
api("/api/config", handleConfigEnv)
|
||||
api("/api/engine/cmdline", handleEngineCmdline) // commande du dernier lancement du moteur (amont #108)
|
||||
api("/api/model/layers", handleModelLayers) // nombre de couches du modèle, lu dans le GGUF (amont #43)
|
||||
api("/api/paths", handlePaths)
|
||||
api("/api/update", handleUpdateCheck)
|
||||
api("/api/update/apply", handleUpdateApply)
|
||||
|
||||
Reference in new issue
Block a user