mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Stockage : Loki signale un /data ou /models servi par le FUSE d'Unraid
Sur Unraid, /mnt/user/… n'est pas un disque mais shfs, un démon FUSE : chaque fsync de loki.db et chaque page d'un gros MoE mmappé relue depuis le disque (modèle plus gros que la RAM) le traverse, à chaque token. Rien n'est perdu ni altéré, mais le décodage ralentit — sans que rien ne le dise. Loki ne déplace rien : changer le chemin hôte d'une installation donnerait un /data vide. Il le voit et le dit, sur un ton d'information : - sys_storagefuse.go lit /proc/self/mountinfo (type lu après le séparateur « - », montage le plus long qui contient le chemin) pour LOKI_HOME et chaque dossier de modèles ; Linux en conteneur seulement, mis en cache une minute puisque /api/status est interrogé en boucle ; - conseil en jaune au démarrage et champ « hint » de /api/status, affiché en encart discret, distinct du bandeau rouge de perte de données ; - compose Unraid et README : « Exclusive access » recommandé (même chemin, FUSE court-circuité), /mnt/<pool> en option avancée avec migration manuelle et mise en garde sur un pool inexistant (RAM). La détection de thrash du cache de pages est laissée de côté : elle exige un signal majflt calibré par modèle sur le serveur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
667e340e57
commit
056f307983
9 files changed
+333
No files matched your search
@@ -167,6 +167,23 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
|
||||
emplacements différents dès que le partage n'est pas en cache-only ou que le
|
||||
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
|
||||
|
||||
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
|
||||
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
|
||||
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
|
||||
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
|
||||
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
|
||||
rien n'est perdu ni altéré. Pour l'éviter :
|
||||
|
||||
- **recommandé** — passe le partage `appdata` (et celui des modèles) en
|
||||
*Exclusive access* (Unraid 6.12+, partage sur un seul pool) : le chemin
|
||||
`/mnt/user/…` ne change pas, FUSE est court-circuité ;
|
||||
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
|
||||
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
|
||||
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
|
||||
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
|
||||
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
|
||||
changer le chemin sans migrer donne un `/data` vide.
|
||||
|
||||
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
|
||||
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
|
||||
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
|
||||
|
||||
@@ -40,6 +40,21 @@ services:
|
||||
# rebinding). Inutile pour un accès par IP ou nom local.
|
||||
- LOKI_TRUSTED_HOSTS=
|
||||
volumes:
|
||||
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
||||
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
||||
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
|
||||
# démon en espace utilisateur. Loki le signale au démarrage (encart
|
||||
# d'information, rien n'est perdu). Deux remèdes, au choix :
|
||||
# - Recommandé : Shares -> appdata (et le partage des modèles) ->
|
||||
# « Exclusive access » = Yes (Unraid 6.12+, partage sur un seul
|
||||
# pool). Le chemin /mnt/user reste le même, FUSE est court-circuité.
|
||||
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
|
||||
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
|
||||
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
|
||||
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
|
||||
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
|
||||
# et un modèle de 80 Go peut saturer la mémoire du serveur.
|
||||
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
|
||||
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package loki
|
||||
|
||||
// sys_storagefuse.go — conseil de PERFORMANCE pour Unraid : /data et /models
|
||||
// servis par la couche FUSE des partages (fuse.shfs).
|
||||
//
|
||||
// Sur Unraid, /mnt/user/… n'est pas un disque mais un démon en espace
|
||||
// utilisateur (shfs) qui agrège array et pools. Tout y passe : chaque fsync de
|
||||
// loki.db, et surtout chaque page du .gguf que le noyau doit relire quand un
|
||||
// gros MoE mmappé ne tient pas en RAM — pendant le décodage, à chaque token.
|
||||
// Rien de perdu, rien d'altéré (les octets du modèle sont les mêmes), mais tout
|
||||
// est plus lent.
|
||||
//
|
||||
// Loki ne déplace RIEN : changer le chemin hôte d'une installation existante
|
||||
// donnerait un /data vide et ferait croire à une perte de données (voir le
|
||||
// README). Il se contente de le VOIR et de le DIRE, sur un ton d'information —
|
||||
// pas le bandeau rouge de sys_datavolume.go, réservé à la perte de données. Le
|
||||
// remède sans changement de chemin : passer les partages en « Exclusive
|
||||
// access » (Unraid 6.12+), qui court-circuite shfs.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mountEntry : ce qu'on retient d'une ligne de /proc/self/mountinfo.
|
||||
type mountEntry struct {
|
||||
point string // point de montage (5ᵉ champ), échappements décodés
|
||||
fsType string // type de système de fichiers (1ᵉʳ champ après « - »)
|
||||
}
|
||||
|
||||
// parseMountinfo lit le format de /proc/<pid>/mountinfo. Le nombre de champs
|
||||
// optionnels (shared:N, master:N…) varie d'une ligne à l'autre : le type de
|
||||
// système de fichiers se lit donc APRÈS le séparateur « - », jamais à un index
|
||||
// fixe. Les lignes malformées sont ignorées.
|
||||
func parseMountinfo(r io.Reader) []mountEntry {
|
||||
var out []mountEntry
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
sep := -1
|
||||
for i := 5; i < len(fields); i++ {
|
||||
if fields[i] == "-" {
|
||||
sep = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sep < 0 || sep+1 >= len(fields) {
|
||||
continue
|
||||
}
|
||||
out = append(out, mountEntry{
|
||||
point: path.Clean(unescapeMountinfo(fields[4])),
|
||||
fsType: fields[sep+1],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unescapeMountinfo décode les séquences octales du noyau (\040 espace,
|
||||
// \011 tabulation, \012 saut de ligne, \134 antislash).
|
||||
func unescapeMountinfo(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mountFSType renvoie le type de système de fichiers qui porte path : celui du
|
||||
// point de montage le plus LONG qui le contient (/data/models peut être un
|
||||
// montage imbriqué distinct de /data). À longueur égale, la dernière ligne
|
||||
// gagne — c'est le montage empilé par-dessus, celui qu'on voit vraiment.
|
||||
// "" quand aucun montage ne couvre le chemin. Chemins Linux : package path, et
|
||||
// non filepath, pour que les tests lisent la même chose sous Windows.
|
||||
func mountFSType(mounts []mountEntry, target string) string {
|
||||
p := path.Clean(target)
|
||||
best, fs := -1, ""
|
||||
for _, m := range mounts {
|
||||
mp := m.point
|
||||
if mp != "/" && p != mp && !strings.HasPrefix(p, mp+"/") {
|
||||
continue
|
||||
}
|
||||
if len(mp) >= best {
|
||||
best, fs = len(mp), m.fsType
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
// shfsPaths renvoie, parmi paths, ceux qui passent par fuse.shfs (dédoublonnés,
|
||||
// dans l'ordre reçu).
|
||||
func shfsPaths(mounts []mountEntry, paths []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
p = path.Clean(p)
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
if mountFSType(mounts, p) == "fuse.shfs" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storageHintText formule le conseil pour les chemins concernés ("" si aucun).
|
||||
// Conseil uniquement : les fichiers sont intacts, seule la vitesse en pâtit.
|
||||
func storageHintText(paths []string) string {
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "ℹ️ Stockage via la couche FUSE des partages Unraid (fuse.shfs) : " + strings.Join(paths, ", ") + ". " +
|
||||
"Les écritures de la base et les pages du modèle relues depuis le disque (gros modèle qui ne tient pas en RAM) " +
|
||||
"traversent un démon en espace utilisateur, ce qui ralentit le décodage. Rien n'est perdu. " +
|
||||
"Remède sans changer de chemin : partages appdata/modèles en « Exclusive access » (Unraid 6.12+). " +
|
||||
"Voir docker-compose.unraid.yml."
|
||||
}
|
||||
|
||||
// storageHint mis en cache : /api/status est interrogé en boucle, et la liste
|
||||
// des dossiers de modèles passe par la base. Les montages ne bougent pas pendant
|
||||
// la vie du conteneur ; une minute suffit à refléter un dossier ajouté dans l'UI.
|
||||
var storageHintCache struct {
|
||||
sync.Mutex
|
||||
at time.Time
|
||||
val string
|
||||
}
|
||||
|
||||
// storageFuseHint renvoie le conseil « /data ou /models sur fuse.shfs » ("" si
|
||||
// sans objet). Linux en conteneur seulement, comme dataVolumeWarning : hors
|
||||
// conteneur, l'utilisateur gère ses chemins.
|
||||
func storageFuseHint() string {
|
||||
if runtime.GOOS != "linux" || os.Getenv("LOKI_CONTAINER") == "" {
|
||||
return ""
|
||||
}
|
||||
c := &storageHintCache
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if !c.at.IsZero() && time.Since(c.at) < time.Minute {
|
||||
return c.val
|
||||
}
|
||||
c.at = time.Now()
|
||||
c.val = ""
|
||||
f, err := os.Open("/proc/self/mountinfo")
|
||||
if err != nil {
|
||||
return "" // illisible : on ne conseille rien plutôt que de deviner
|
||||
}
|
||||
defer f.Close()
|
||||
mounts := parseMountinfo(f)
|
||||
paths := append([]string{LokiHome()}, modelDirs()...)
|
||||
c.val = storageHintText(shfsPaths(mounts, paths))
|
||||
return c.val
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Extrait réaliste d'un conteneur Loki sur Unraid : racine overlay, /data et
|
||||
// /models en bind depuis /mnt/user (fuse.shfs), un /data/models imbriqué sur
|
||||
// un pool btrfs, champs optionnels de longueur variable, un chemin à espace.
|
||||
const mountinfoUnraid = `612 553 0:156 / / rw,relatime master:221 - overlay overlay rw,lowerdir=/var/lib/docker/l
|
||||
613 612 0:159 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
|
||||
620 612 0:52 /appdata/loki/data /data rw,noatime - fuse.shfs shfs rw,user_id=0,group_id=0,allow_other
|
||||
621 612 0:52 /appdata/loki/models /models rw,noatime shared:5 master:7 - fuse.shfs shfs rw,user_id=0
|
||||
622 620 0:61 /appdata/loki/fast /data/models rw,noatime - btrfs /dev/nvme0n1p1 rw,ssd
|
||||
623 612 0:52 /media /mnt/mes\040modeles rw,noatime - fuse.shfs shfs rw
|
||||
garbage line
|
||||
624 612 8:1 / /broken rw
|
||||
`
|
||||
|
||||
func TestParseMountinfo(t *testing.T) {
|
||||
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
|
||||
if len(m) != 6 {
|
||||
t.Fatalf("attendu 6 montages valides, obtenu %d : %+v", len(m), m)
|
||||
}
|
||||
// Champs optionnels (shared:5 master:7) : le type se lit après « - ».
|
||||
if m[3].point != "/models" || m[3].fsType != "fuse.shfs" {
|
||||
t.Fatalf("ligne à champs optionnels mal lue : %+v", m[3])
|
||||
}
|
||||
if m[5].point != "/mnt/mes modeles" {
|
||||
t.Fatalf("échappement \\040 non décodé : %q", m[5].point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountFSType(t *testing.T) {
|
||||
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
|
||||
cases := []struct {
|
||||
path, want string
|
||||
}{
|
||||
{"/data", "fuse.shfs"},
|
||||
{"/data/loki.db", "fuse.shfs"},
|
||||
{"/data/models", "btrfs"}, // montage imbriqué : le plus long gagne
|
||||
{"/data/models/x.gguf", "btrfs"},
|
||||
{"/data/modelsX", "fuse.shfs"}, // préfixe de chaîne ≠ préfixe de chemin
|
||||
{"/models", "fuse.shfs"},
|
||||
{"/mnt/mes modeles/a.gguf", "fuse.shfs"},
|
||||
{"/opt/loki", "overlay"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mountFSType(m, c.path); got != c.want {
|
||||
t.Errorf("mountFSType(%q) = %q, attendu %q", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := mountFSType(nil, "/data"); got != "" {
|
||||
t.Errorf("sans montage connu : %q, attendu \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShfsPaths(t *testing.T) {
|
||||
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
|
||||
got := shfsPaths(m, []string{"/data", "/data/models", "/models", "/models/", "/opt"})
|
||||
want := []string{"/data", "/models"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("shfsPaths = %v, attendu %v", got, want)
|
||||
}
|
||||
// Partages en « Exclusive access » : plus de fuse.shfs, plus de conseil.
|
||||
excl := `1 0 0:1 / / rw - overlay overlay rw
|
||||
2 1 0:2 /appdata/loki/data /data rw - btrfs /dev/nvme0n1p1 rw
|
||||
3 1 0:3 /loki/models /models rw - xfs /dev/md1p1 rw
|
||||
`
|
||||
if p := shfsPaths(parseMountinfo(strings.NewReader(excl)), []string{"/data", "/models"}); len(p) != 0 {
|
||||
t.Fatalf("aucun chemin ne devrait être signalé, obtenu %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageHintText(t *testing.T) {
|
||||
if s := storageHintText(nil); s != "" {
|
||||
t.Fatalf("rien à signaler : %q", s)
|
||||
}
|
||||
s := storageHintText([]string{"/data", "/models"})
|
||||
for _, w := range []string{"/data, /models", "fuse.shfs", "Exclusive access"} {
|
||||
if !strings.Contains(s, w) {
|
||||
t.Errorf("conseil sans %q : %s", w, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageFuseHintOutsideContainer(t *testing.T) {
|
||||
t.Setenv("LOKI_CONTAINER", "")
|
||||
if s := storageFuseHint(); s != "" {
|
||||
t.Fatalf("hors conteneur, aucun conseil attendu : %q", s)
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Setenv("LOKI_CONTAINER", "1")
|
||||
if s := storageFuseHint(); s != "" {
|
||||
t.Fatalf("hors Linux, aucun conseil attendu : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2301,6 +2301,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="app-hint" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;border:1px solid var(--border)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
|
||||
OU par son propre chevron — c'est une section comme les autres, sinon on ne
|
||||
@@ -4042,6 +4043,13 @@ async function loadStatus(){
|
||||
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
|
||||
else { wb.style.display='none'; }
|
||||
}
|
||||
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
|
||||
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
|
||||
const hb=document.getElementById('app-hint');
|
||||
if(hb){
|
||||
if(s.hint){ hb.textContent=s.hint; hb.style.display=''; }
|
||||
else { hb.style.display='none'; }
|
||||
}
|
||||
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
|
||||
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
|
||||
const me=document.getElementById('model-err');
|
||||
|
||||
@@ -75,6 +75,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="app-hint" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;border:1px solid var(--border)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
|
||||
OU par son propre chevron — c'est une section comme les autres, sinon on ne
|
||||
|
||||
@@ -50,6 +50,13 @@ async function loadStatus(){
|
||||
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
|
||||
else { wb.style.display='none'; }
|
||||
}
|
||||
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
|
||||
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
|
||||
const hb=document.getElementById('app-hint');
|
||||
if(hb){
|
||||
if(s.hint){ hb.textContent=s.hint; hb.style.display=''; }
|
||||
else { hb.style.display='none'; }
|
||||
}
|
||||
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
|
||||
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
|
||||
const me=document.getElementById('model-err');
|
||||
|
||||
@@ -83,6 +83,9 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// id du preset actif : un autre appareil a pu basculer, l'UI se
|
||||
// resynchronise sans reload (voir loadStatus). AJEAN 0.13.6.
|
||||
"preset": activePresetID(),
|
||||
// Conseil de performance (stockage sur fuse.shfs d'Unraid) : encart
|
||||
// discret, distinct de « warn » qui annonce une perte de données.
|
||||
"hint": storageFuseHint(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ func cmdWeb(args []string) error {
|
||||
if dv := dataVolumeWarning(); dv != "" {
|
||||
fmt.Println(red(dv))
|
||||
}
|
||||
// Simple conseil de performance (fuse.shfs d'Unraid) : jaune, pas rouge —
|
||||
// rien n'est perdu, seul le décodage d'un gros modèle hors RAM ralentit.
|
||||
if sh := storageFuseHint(); sh != "" {
|
||||
fmt.Println(yellow(sh))
|
||||
}
|
||||
if readWebKey() == "" {
|
||||
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
|
||||
fmt.Printf(" %s\n", bold("loki set-web-key"))
|
||||
|
||||
Reference in new issue
Block a user