mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'agent produit des rapports, des scripts, des exports et des captures dans son dossier de travail. On ne pouvait en récupérer un que si le modèle avait pensé à en mettre le lien dans sa réponse : tout ce qu'il écrivait sans le dire restait invisible, et faire le ménage demandait un shell. Un panneau de droite, ouvert et fermé par le bouton dossier du pied de carte, liste ce dossier avec navigation dans les sous-dossiers, téléchargement et suppression. Il pousse la conversation au lieu de la recouvrir — la largeur de lecture étant pilotée par --measure, le fil se resserre et se recentre tout seul. Sur téléphone c'est un tiroir, comme les réglages à gauche. Le socle existait : /api/chat/file téléchargeait déjà, workspaceRel bornait déjà les chemins, downloadWorkspaceFile gérait déjà le blob. Manquaient les deux verbes qui comptent, /api/chat/files pour lister et /api/chat/file/delete pour supprimer. Trois choix qui méritent d'être dits : - Un dossier affiche la taille de TOUT son contenu, pas celle de son inode : c'est ce qu'on libère en le supprimant, donc c'est le chiffre qui aide à décider. Le parcours est borné à 20 000 entrées pour que la liste reste instantanée si l'agent y dézippe quelque chose d'énorme. - La racine du dossier de travail est refusée à la suppression : l'agent y perd le dossier dans lequel il écrit, et « tout effacer » ne doit pas être à un clic. La suppression d'un dossier annonce le nombre de fichiers et le poids emportés avant de demander confirmation. - Le panneau ne se remplit qu'à l'ouverture. Un ReadDir récursif à chaque chargement de page pour un panneau fermé serait payé par tout le monde. Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` : la gouttière de barre de défilement mesurée dans --sbw resterait celle de l'ancienne largeur et la carte de saisie serait décalée du fil. On resynchronise donc explicitement ; la hauteur, elle, est déjà suivie par un ResizeObserver. Vérifié. Six tests Go sur le bornage, qui est la partie sensible — ces routes suppriment sur disque à partir d'un chemin venu du navigateur : « .. », chemins absolus, racine, et un lien symbolique posé dans le dossier de travail sont tous refusés, et le fichier voisin survit. Puis au navigateur, en clair et en sombre : listing trié dossiers d'abord, taille récursive, descente et fil d'Ariane, téléchargement réel (l'événement download porte bien rapport.md), suppression confirmée puis disparition de la ligne, persistance de l'état ouvert, tiroir mobile avec voile, et la saisie reste alignée au pixel sur le fil (0 px des deux côtés) une fois le panneau ouvert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
383 lines
17 KiB
Go
383 lines
17 KiB
Go
package loki
|
|
|
|
import (
|
|
"bufio"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:generate go run ../../tools/assemble-ui ui
|
|
//go:embed ui/index.html ui/marked.min.js ui/fonts/*.woff2
|
|
var uiFS embed.FS
|
|
|
|
// cmdWeb starts the HTTP server on the given port (default 8090).
|
|
func cmdWeb(args []string) error {
|
|
port := 8090
|
|
if len(args) > 0 && args[0] != "" {
|
|
n, err := strconv.Atoi(args[0])
|
|
if err != nil {
|
|
return fmt.Errorf("port invalide: %s", args[0])
|
|
}
|
|
port = n
|
|
}
|
|
mux := newWebMux()
|
|
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
// Port occupé : on identifie le process qui le tient et on propose de
|
|
// le terminer pour relancer à sa place.
|
|
if !resolvePortConflict(port) {
|
|
return err
|
|
}
|
|
if ln, err = net.Listen("tcp", addr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Printf("[loki web] http://%s (Ctrl-C pour arrêter)\n", addr)
|
|
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"))
|
|
} else {
|
|
fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
|
|
}
|
|
|
|
// Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui
|
|
// sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation
|
|
// unique — elle vit en mémoire (voir chat_conversation.go), donc deux process
|
|
// qui la servent, c'est deux fils qui divergent et s'écrasent l'un l'autre.
|
|
// Sans jeton enregistré, startAppLink ne fait rien : l'UI locale suffit.
|
|
appOwnsLink = true
|
|
appWebMux = mux
|
|
if readLinkToken() != "" {
|
|
killForeignUIWorker() // rescapé d'une version antérieure ou d'une autre copie
|
|
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
|
|
if fp := e2eFingerprint(); fp != "" {
|
|
fmt.Printf("%s empreinte E2E : %s (code d'appairage : loki link code)\n", green("[e2e]"), bold(fp))
|
|
}
|
|
fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled())
|
|
startAppLink(mux)
|
|
}
|
|
// ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête
|
|
// immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0.
|
|
// Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui
|
|
// restent ouverts aussi longtemps que l'utilisateur regarde la page.
|
|
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
|
return srv.Serve(ln)
|
|
}
|
|
|
|
// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être
|
|
// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans
|
|
// repasser par un écouteur TCP local.
|
|
var convLoadOnce sync.Once
|
|
|
|
func newWebMux() *http.ServeMux {
|
|
// Charge l'état de conversation persisté (une fois par process : loki web ET
|
|
// loki link serve appellent newWebMux).
|
|
convLoadOnce.Do(LoadConversation)
|
|
// Pré-chauffe les serveurs MCP en tâche de fond : sinon le handshake (plusieurs
|
|
// secondes pour un serveur lancé via npx) est payé par le premier message.
|
|
MCPPrewarm()
|
|
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
|
|
// .part orphelin non reprenable : on nettoie au démarrage.
|
|
cleanStalePartFiles()
|
|
// Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent
|
|
// en mémoire, aucun .part ne survit utilement à l'arrêt du process.
|
|
cleanStaleUploadParts()
|
|
// Idem pour une installation de moteur : elle meurt avec le process. On
|
|
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
|
|
// plus rien du tout.
|
|
lcRestoreOnce.Do(lcRestore)
|
|
mux := http.NewServeMux()
|
|
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
|
|
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
|
|
mux.HandleFunc("/", handleIndex)
|
|
// Poste distant : deux routes PUBLIQUES (pas derrière la clé de pilotage).
|
|
// L'enrôlement est authentifié par le code d'appairage à usage unique, et le
|
|
// WebSocket par la clé d'appareil (Bearer) — le poste ne connaît pas la clé
|
|
// de pilotage. Voir node_api.go / node_server.go.
|
|
mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
|
|
mux.HandleFunc("/api/node/ws", handleNodeWS)
|
|
mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := uiFS.ReadFile("ui/marked.min.js")
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Write(b)
|
|
})
|
|
// Polices embarquées (Bricolage Grotesque + IBM Plex Mono, sous-ensemble
|
|
// latin). Servies par Loki et non par Google Fonts : une instance locale ou
|
|
// derrière un réseau fermé doit s'afficher correctement sans appeler un tiers,
|
|
// et sans lui signaler qui consulte l'interface.
|
|
mux.HandleFunc("/fonts/", func(w http.ResponseWriter, r *http.Request) {
|
|
name := path.Base(r.URL.Path)
|
|
if !strings.HasSuffix(name, ".woff2") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
b, err := uiFS.ReadFile("ui/fonts/" + name)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "font/woff2")
|
|
// Immuables : leur nom change si leur contenu change (nouvelle version
|
|
// de l'image), donc un an de cache est sans risque.
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
w.Write(b)
|
|
})
|
|
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
|
|
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
|
api("/api/ping", handlePing)
|
|
api("/api/status", handleStatus)
|
|
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
|
api("/api/vram", handleVram)
|
|
api("/api/ram", handleRam)
|
|
api("/api/config", handleConfigEnv)
|
|
api("/api/paths", handlePaths)
|
|
api("/api/update", handleUpdateCheck)
|
|
api("/api/update/apply", handleUpdateApply)
|
|
api("/api/models", handleModels)
|
|
api("/api/models/delete", handleModelDelete)
|
|
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
|
api("/api/models/download", handleModelDownload)
|
|
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
|
api("/api/models/download/status", handleModelDownloadStatus)
|
|
api("/api/models/download/cancel", handleModelDownloadCancel)
|
|
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
|
|
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
|
|
api("/api/backends", handleBackends)
|
|
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
|
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
|
api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp
|
|
api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin
|
|
api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN
|
|
api("/api/llamacpp/install-custom", handleLlamacppInstallCustom) // job : clone d'un fork depuis une URL Git (par preset, sans BIN global)
|
|
api("/api/llamacpp/uninstall-custom", handleLlamacppUninstallCustom) // supprime un backend custom (backends/<name>)
|
|
api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart
|
|
api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job
|
|
api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés
|
|
api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée
|
|
api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées
|
|
api("/api/presets", handlePresets)
|
|
api("/api/preset", handlePreset)
|
|
api("/api/preset/save", handlePresetSave)
|
|
api("/api/preset/delete", handlePresetDelete)
|
|
api("/api/agent", handleAgent)
|
|
api("/api/agent/toggle", handleAgentToggle)
|
|
api("/api/agent/compact", handleCompactToggle)
|
|
api("/api/apikey", handleAPIKey)
|
|
api("/api/oai/public", handleOAIPublic)
|
|
api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link)
|
|
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → loki link
|
|
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
|
|
api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé
|
|
api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion
|
|
// Postes distants : gestion réservée au propriétaire (clé de pilotage).
|
|
api("/api/node", handleNodes) // liste + état de connexion
|
|
api("/api/node/pair", handleNodePair) // génère un code d'appairage
|
|
api("/api/node/caps", handleNodeCaps) // règle capacités + dossier racine
|
|
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
|
|
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
|
api("/api/internet", handleInternet)
|
|
api("/api/mcp", handleMCP)
|
|
api("/api/mcp/save", handleMCPSave)
|
|
api("/api/mcp/delete", handleMCPDelete)
|
|
api("/api/mcp/toggle", handleMCPToggle)
|
|
api("/api/mcp/tool", handleMCPTool)
|
|
api("/api/mcp/test", handleMCPTest)
|
|
api("/api/memory", handleMemoryMode)
|
|
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
|
api("/api/prefs", handleWebPrefs)
|
|
api("/api/sysprompt", handleSysPrompt)
|
|
// Alias rétro-compat : l'ancien portail ajean.link (dépôt loki-relay) pilote
|
|
// encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E.
|
|
// On les mappe sur le mode agent unifié le temps que le portail soit mis à jour.
|
|
api("/api/tools", handleAgent)
|
|
api("/api/tools/toggle", handleAgentToggle)
|
|
api("/api/skills", handleAgent)
|
|
api("/api/skills/toggle", handleAgentToggle)
|
|
api("/api/mem", handleMem)
|
|
api("/api/mem/save", handleMemSave)
|
|
api("/api/mem/delete", handleMemDelete)
|
|
api("/api/switch", handleSwitch)
|
|
api("/api/start", svcHandler("start"))
|
|
api("/api/stop", svcHandler("stop"))
|
|
api("/api/restart", svcHandler("restart"))
|
|
api("/api/bench", handleBench)
|
|
api("/api/bench/last", handleBenchLast)
|
|
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
|
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
|
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
|
api("/api/chat/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
|
|
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
|
|
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
|
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
|
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
|
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
|
// Discussions : liste, création, bascule, renommage, suppression.
|
|
api("/api/conversations", handleConvList)
|
|
api("/api/conversations/new", handleConvNew)
|
|
api("/api/conversations/switch", handleConvSwitch)
|
|
api("/api/conversations/rename", handleConvRename)
|
|
api("/api/conversations/delete", handleConvDelete)
|
|
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
|
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
|
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
|
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
|
return mux
|
|
}
|
|
|
|
// resolvePortConflict identifies the process listening on `port`, asks the user
|
|
// whether to terminate it, and (on yes) kills it and waits for the port to free.
|
|
// Returns true if the caller should retry binding.
|
|
func resolvePortConflict(port int) bool {
|
|
pid, name := pidOnPort(port)
|
|
if pid == 0 {
|
|
fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port)
|
|
return false
|
|
}
|
|
fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid)
|
|
fmt.Print(dim(" terminer ce process et relancer ? [Y/n] "))
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
|
fmt.Println(dim(" annulé."))
|
|
return false
|
|
}
|
|
// Arrêt poli d'abord, puis forcé si le port ne se libère pas.
|
|
killPid(pid, false)
|
|
for i := 0; i < 15; i++ {
|
|
time.Sleep(200 * time.Millisecond)
|
|
if p, _ := pidOnPort(port); p == 0 {
|
|
fmt.Printf("%s process %d terminé, redémarrage…\n", green("[ok]"), pid)
|
|
return true
|
|
}
|
|
}
|
|
killPid(pid, true)
|
|
time.Sleep(500 * time.Millisecond)
|
|
if p, _ := pidOnPort(port); p != 0 {
|
|
fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p)
|
|
return false
|
|
}
|
|
fmt.Printf("%s process %d terminé (forcé), redémarrage…\n", green("[ok]"), pid)
|
|
return true
|
|
}
|
|
|
|
// killPid termine un process : kill TERM/KILL sous Unix, taskkill sous Windows
|
|
// (où il n'existe pas d'arrêt « poli » générique — taskkill sans /F échoue sur
|
|
// les process console, donc le second essai passe en forcé).
|
|
func killPid(pid int, force bool) {
|
|
if runtime.GOOS == "windows" {
|
|
args := []string{"/PID", strconv.Itoa(pid)}
|
|
if force {
|
|
args = append(args, "/F")
|
|
}
|
|
_ = hideCmd(exec.Command("taskkill", args...)).Run()
|
|
return
|
|
}
|
|
sig := "-TERM"
|
|
if force {
|
|
sig = "-KILL"
|
|
}
|
|
_ = exec.Command("kill", sig, strconv.Itoa(pid)).Run()
|
|
}
|
|
|
|
// pidOnPort returns the PID and command name of the process listening on the
|
|
// given TCP port, via `ss` (Linux) with an `lsof` fallback, or `netstat -ano`
|
|
// on Windows. Returns 0 if none is found or if the tools can't see it (e.g.
|
|
// owned by another user).
|
|
func pidOnPort(port int) (int, string) {
|
|
if runtime.GOOS == "windows" {
|
|
// netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234"
|
|
out, err := hideCmd(exec.Command("netstat", "-ano", "-p", "tcp")).Output()
|
|
if err != nil {
|
|
return 0, ""
|
|
}
|
|
suffix := ":" + strconv.Itoa(port)
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) >= 5 && f[0] == "TCP" && strings.HasSuffix(f[1], suffix) && f[3] == "LISTENING" {
|
|
if pid, err := strconv.Atoi(f[4]); err == nil && pid > 0 {
|
|
return pid, processName(pid)
|
|
}
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
redir := regexp.MustCompile(`pid=(\d+)`)
|
|
if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil {
|
|
if m := redir.FindStringSubmatch(string(out)); m != nil {
|
|
pid, _ := strconv.Atoi(m[1])
|
|
return pid, processName(pid)
|
|
}
|
|
}
|
|
if out, err := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port), "-sTCP:LISTEN").Output(); err == nil {
|
|
for _, line := range strings.Fields(string(out)) {
|
|
if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
|
|
return pid, processName(pid)
|
|
}
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
// processName returns a short command name for a PID, or "?" if unknown.
|
|
func processName(pid int) string {
|
|
if runtime.GOOS == "windows" {
|
|
// tasklist CSV : "loki.exe","1234","Console","1","12 345 K"
|
|
out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output()
|
|
if err == nil {
|
|
if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 {
|
|
return strings.TrimPrefix(f[0], "\"")
|
|
}
|
|
}
|
|
return "?"
|
|
}
|
|
if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil {
|
|
if n := strings.TrimSpace(string(b)); n != "" {
|
|
return n
|
|
}
|
|
}
|
|
if out, err := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output(); err == nil {
|
|
if n := strings.TrimSpace(string(out)); n != "" {
|
|
return n
|
|
}
|
|
}
|
|
return "?"
|
|
}
|
|
|
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
b, err := uiFS.ReadFile("ui/index.html")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
|
w.Write(b)
|
|
}
|
|
|
|
func sendJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// handlePing is a lightweight authenticated endpoint a client hits to verify
|
|
// connectivity AND that its key is valid (200 = bonne clé, 401 = mauvaise clé).
|