mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
- Au démarrage, une lecture ratée de la base (verrou bbolt transitoire pendant le chevauchement des process au redémarrage du conteneur) était confondue avec une conversation absente. Pire que l'amont chez nous : getStr rend "" dans les deux cas, donc convEnsureActive forgeait un NOUVEL identifiant et l'écrasait — le fil en cours devenait orphelin, en silence. On sonde désormais la base avec son erreur AVANT toute écriture, on réessaie quatre fois, et un échec durable est journalisé sans que rien ne soit touché. - Une capture d'écran disparaissait sans laisser de trace : stripImageParts aplatissait le message en ne gardant que sa légende. Le marqueur imageLostMarker rend la perte VISIBLE, pour que le modèle sache reprendre une capture au lieu de la redécrire de mémoire. - maxLogEvents 20000 → 200000 : un seul tour à très long raisonnement tronquait déjà le journal de rejeu, et l'utilisateur perdait le début de sa conversation à l'écran. - Keepalive WebSocket des postes distants (ping toutes les 25 s, des DEUX côtés). Sans lui, un poste au repos était coupé au bout de ~60-100 s par les intermédiaires qui ferment les canaux inactifs, puis reconnecté après backoff — les déconnexions à répétition sur tous les postes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
472 lines
14 KiB
Go
472 lines
14 KiB
Go
// Package nodeclient est le CLIENT léger « poste distant ». Il tourne sur un PC
|
|
// secondaire, s'appaire une fois avec le serveur loki, puis ouvre une connexion
|
|
// SORTANTE et exécute — sous contrôle local — les demandes de l'IA.
|
|
//
|
|
// Volontairement autonome : dépend seulement de nodewire (protocole partagé,
|
|
// zéro dépendance) et de coder/websocket. AUCUN import d'loki → binaire de
|
|
// quelques Mo, sans UI ni moteur.
|
|
//
|
|
// Sécurité côté poste : allowlist locale des capacités, confinement des fichiers
|
|
// à un dossier racine, confirmation des actions à effet de bord (sauf --yes).
|
|
package nodeclient
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/R0m1k3/Loki/internal/nodewire"
|
|
"github.com/coder/websocket"
|
|
"github.com/coder/websocket/wsjson"
|
|
)
|
|
|
|
const (
|
|
defaultTimeoutSec = 30
|
|
maxTimeoutSec = 300
|
|
maxOutput = 8000 // caractères de stdout/stderr renvoyés
|
|
readMax = 100000 // caractères max d'une lecture de fichier
|
|
// nodePingInterval : cadence du keepalive WebSocket. 25 s tient sous les délais
|
|
// d'inactivité usuels des proxys et relais (~60-100 s). Doit rester alignée sur
|
|
// la constante homonyme côté serveur (node_server.go).
|
|
nodePingInterval = 25 * time.Second
|
|
// nodePingTimeout : au-delà, le serveur ne répond plus — on ferme, et Run
|
|
// reconnecte proprement au lieu de garder une connexion morte.
|
|
nodePingTimeout = 10 * time.Second
|
|
)
|
|
|
|
// Config persiste l'appairage et les préférences locales du poste.
|
|
//
|
|
// L'identité du poste = sa paire de clés X25519 (Priv/Pub). Le canal vers l'agent
|
|
// est chiffré de bout en bout (relais aveugle). AgentPub est la clé publique de
|
|
// l'agent (fournie à l'install, hors-bande, via la commande d'appairage) : le
|
|
// poste scelle vers elle et en dérive la clé de canal. MachineID, s'il est
|
|
// renseigné, fait passer par le relais ajean.link (/node/<machine>/).
|
|
type Config struct {
|
|
ServerURL string `json:"server_url"`
|
|
ID string `json:"id"`
|
|
Priv string `json:"priv"` // clé privée X25519 du poste (hex) — LE secret
|
|
Pub string `json:"pub"` // clé publique X25519 du poste (hex)
|
|
AgentPub string `json:"agent_pub"`
|
|
MachineID string `json:"machine_id,omitempty"` // non vide = passer par le relais
|
|
Name string `json:"name"`
|
|
Caps []string `json:"caps"`
|
|
Root string `json:"root"`
|
|
AutoYes bool `json:"auto_yes"`
|
|
}
|
|
|
|
// baseURL est la racine à laquelle joindre l'agent : directe (LAN) ou via le
|
|
// relais ajean.link quand un MachineID est configuré.
|
|
func (c Config) baseURL() string {
|
|
u := strings.TrimRight(c.ServerURL, "/")
|
|
if c.MachineID != "" {
|
|
u += "/node/" + c.MachineID
|
|
}
|
|
return u
|
|
}
|
|
|
|
// dataDir est le dossier de données du poste. Sur Windows : C:\ProgramData\
|
|
// loki-remote — MACHINE-WIDE, pour que le service (qui tourne en LocalSystem) lise
|
|
// la MÊME config que celle écrite par `install` (le profil utilisateur de
|
|
// LocalSystem est différent du tien). Ailleurs : dossier de config utilisateur.
|
|
func dataDir() string {
|
|
if runtime.GOOS == "windows" {
|
|
if pd := os.Getenv("ProgramData"); pd != "" {
|
|
return filepath.Join(pd, "loki-remote")
|
|
}
|
|
}
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil || dir == "" {
|
|
dir, _ = os.Getwd()
|
|
}
|
|
return filepath.Join(dir, "loki-remote")
|
|
}
|
|
|
|
// ConfigPath renvoie le chemin du fichier de configuration.
|
|
func ConfigPath() string { return filepath.Join(dataDir(), "config.json") }
|
|
|
|
// InstalledExePath est l'emplacement stable où `install` copie le binaire, pour
|
|
// qu'il survive à la suppression du téléchargement.
|
|
func InstalledExePath() string {
|
|
name := "loki-remote"
|
|
if runtime.GOOS == "windows" {
|
|
name += ".exe"
|
|
}
|
|
return filepath.Join(dataDir(), name)
|
|
}
|
|
|
|
// DataDir est exporté pour les installateurs par plateforme.
|
|
func DataDir() string { return dataDir() }
|
|
|
|
// SanitizeCaps filtre une liste de capacités (réexport de nodewire pour la CLI).
|
|
func SanitizeCaps(caps []string) []string { return nodewire.SanitizeCaps(caps) }
|
|
|
|
func LoadConfig() (Config, error) {
|
|
var c Config
|
|
b, err := os.ReadFile(ConfigPath())
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
return c, json.Unmarshal(b, &c)
|
|
}
|
|
|
|
func SaveConfig(c Config) error {
|
|
p := ConfigPath()
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
return err
|
|
}
|
|
b, _ := json.MarshalIndent(c, "", " ")
|
|
return os.WriteFile(p, b, 0o600) // 0600 : la clé d'appareil est un secret
|
|
}
|
|
|
|
// Enroll appaire le poste : génère sa paire de clés, SCELLE {pub, code, name, os}
|
|
// vers la clé publique de l'agent (le relais ne voit rien), et l'envoie. La clé
|
|
// privée ne quitte JAMAIS le poste.
|
|
func Enroll(cfg *Config, code string) error {
|
|
if cfg.AgentPub == "" {
|
|
return errors.New("clé publique de l'agent requise (--key, fournie par la commande d'appairage)")
|
|
}
|
|
priv, pub, err := nodewire.GenKeyPair()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name, _ := os.Hostname()
|
|
inner, _ := json.Marshal(map[string]string{
|
|
"pub": pub, "code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH,
|
|
})
|
|
blob, err := nodewire.SealTo(cfg.AgentPub, inner)
|
|
if err != nil {
|
|
return fmt.Errorf("scellement: %w", err)
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
|
|
endpoint := cfg.baseURL() + "/api/node/enroll"
|
|
req, _ := http.NewRequest("POST", endpoint, strings.NewReader(string(body)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("appairage: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
OK bool `json:"ok"`
|
|
ID string `json:"id"`
|
|
Caps []string `json:"caps"`
|
|
Root string `json:"root"`
|
|
Name string `json:"name"`
|
|
Err string `json:"error"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
if !out.OK {
|
|
if out.Err == "" {
|
|
out.Err = "réponse inattendue du serveur (" + resp.Status + ")"
|
|
}
|
|
return errors.New(out.Err)
|
|
}
|
|
cfg.Priv, cfg.Pub, cfg.ID, cfg.Caps = priv, pub, out.ID, out.Caps
|
|
if out.Root != "" {
|
|
cfg.Root = out.Root
|
|
}
|
|
if out.Name != "" {
|
|
cfg.Name = out.Name
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run maintient la connexion pour toujours (reconnexion auto). quiet coupe les
|
|
// impressions (mode service). Bloque jusqu'à ctx.Done().
|
|
func Run(ctx context.Context, cfg Config, quiet bool) {
|
|
backoff := time.Second
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
err := session(ctx, cfg, quiet)
|
|
if err != nil && !quiet {
|
|
fmt.Printf("[node] session terminée: %v — reconnexion dans %s\n", err, backoff)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(backoff):
|
|
}
|
|
if backoff < 30*time.Second {
|
|
backoff *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
func wsURL(cfg Config) string {
|
|
s := cfg.baseURL()
|
|
switch {
|
|
case strings.HasPrefix(s, "https://"):
|
|
s = "wss://" + strings.TrimPrefix(s, "https://")
|
|
case strings.HasPrefix(s, "http://"):
|
|
s = "ws://" + strings.TrimPrefix(s, "http://")
|
|
case !strings.Contains(s, "://"):
|
|
s = "wss://" + s
|
|
}
|
|
return s + "/api/node/ws"
|
|
}
|
|
|
|
func session(ctx context.Context, cfg Config, quiet bool) error {
|
|
dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
|
defer cancel()
|
|
c, _, err := websocket.Dial(dialCtx, wsURL(cfg), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("connexion: %w", err)
|
|
}
|
|
c.SetReadLimit(-1)
|
|
defer c.CloseNow()
|
|
|
|
// Poignée de main : 1er frame EN CLAIR = notre clé publique (elle est publique).
|
|
if err := wsjson.Write(dialCtx, c, map[string]string{"type": "hello_pub", "pub": cfg.Pub}); err != nil {
|
|
return fmt.Errorf("hello_pub: %w", err)
|
|
}
|
|
// Canal chiffré : K = ECDH(notre priv, clé publique agent). Le relais ne l'a pas.
|
|
key, err := nodewire.ChannelKey(cfg.Priv, cfg.AgentPub)
|
|
if err != nil {
|
|
return fmt.Errorf("canal: %w", err)
|
|
}
|
|
ch, err := nodewire.NewChan(key, true) // poste = côté d'envoi 1
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sendEnc := func(m nodewire.Msg) error {
|
|
raw, _ := json.Marshal(m)
|
|
return wsjson.Write(ctx, c, ch.Seal(raw))
|
|
}
|
|
// 1er frame CHIFFRÉ : le hello (nom/os/capacités). Prouve qu'on a la bonne clé.
|
|
if err := sendEnc(nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}); err != nil {
|
|
return fmt.Errorf("hello: %w", err)
|
|
}
|
|
if !quiet {
|
|
fmt.Printf("[node] connecté ✓ (canal chiffré, en attente des demandes de l'IA)\n")
|
|
}
|
|
// Keepalive : un ping WebSocket périodique empêche les intermédiaires (relais,
|
|
// Caddy, Cloudflare) de couper la connexion quand l'IA ne sollicite pas le
|
|
// poste. Sans lui, un canal inactif tombait au bout de ~60-100 s, la session se
|
|
// terminait et Run reconnectait après backoff — d'où les déconnexions à
|
|
// répétition sur tous les postes. coder/websocket sérialise Ping avec les
|
|
// écritures de données : pas de course avec sendEnc.
|
|
pingCtx, stopPing := context.WithCancel(ctx)
|
|
defer stopPing()
|
|
go func() {
|
|
t := time.NewTicker(nodePingInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-pingCtx.Done():
|
|
return
|
|
case <-t.C:
|
|
pc, cancel := context.WithTimeout(pingCtx, nodePingTimeout)
|
|
err := c.Ping(pc)
|
|
cancel()
|
|
if err != nil {
|
|
_ = c.Close(websocket.StatusGoingAway, "ping sans réponse")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
for {
|
|
var fr nodewire.Frame
|
|
if err := wsjson.Read(ctx, c, &fr); err != nil {
|
|
return err
|
|
}
|
|
plain, err := ch.Open(fr)
|
|
if err != nil {
|
|
return fmt.Errorf("déchiffrement: %w", err)
|
|
}
|
|
var m nodewire.Msg
|
|
if err := json.Unmarshal(plain, &m); err != nil {
|
|
return err
|
|
}
|
|
if m.Type != "call" {
|
|
continue
|
|
}
|
|
result := execute(ctx, cfg, m, quiet)
|
|
if err := sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: result}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// execute applique les gardes locales puis exécute la demande.
|
|
func execute(ctx context.Context, cfg Config, m nodewire.Msg, quiet bool) string {
|
|
if !nodewire.CapAllowed(cfg.Caps, m.Cap) {
|
|
return "[refusé] la capacité « " + m.Cap + " » n'est pas activée sur ce poste"
|
|
}
|
|
if !cfg.AutoYes && (m.Cap == nodewire.CapShell || m.Cap == nodewire.CapWrite) {
|
|
if !confirm(m, quiet) {
|
|
return "[refusé] l'utilisateur du poste a refusé cette action"
|
|
}
|
|
}
|
|
switch m.Cap {
|
|
case nodewire.CapShell:
|
|
command, _ := m.Args["command"].(string)
|
|
to := 0
|
|
if v, ok := m.Args["timeout"].(float64); ok {
|
|
to = int(v)
|
|
}
|
|
return runShell(ctx, command, to, cfg.Root)
|
|
case nodewire.CapRead:
|
|
path, _ := m.Args["path"].(string)
|
|
return readFile(cfg.Root, path)
|
|
case nodewire.CapWrite:
|
|
path, _ := m.Args["path"].(string)
|
|
content, _ := m.Args["content"].(string)
|
|
return writeFile(cfg.Root, path, content)
|
|
case nodewire.CapList:
|
|
path, _ := m.Args["path"].(string)
|
|
return listDir(cfg.Root, path)
|
|
}
|
|
return "[erreur] capacité inconnue: " + m.Cap
|
|
}
|
|
|
|
// confirm demande validation sur le terminal du poste. Sans terminal (service)
|
|
// et sans --yes → REFUSE : mieux vaut bloquer qu'exécuter sans surveillance.
|
|
func confirm(m nodewire.Msg, quiet bool) bool {
|
|
fi, _ := os.Stdin.Stat()
|
|
interactive := fi != nil && (fi.Mode()&os.ModeCharDevice) != 0
|
|
if !interactive || quiet {
|
|
return false
|
|
}
|
|
label := ""
|
|
if c, ok := m.Args["command"].(string); ok {
|
|
label = c
|
|
} else if p, ok := m.Args["path"].(string); ok {
|
|
label = p
|
|
}
|
|
fmt.Printf("\n[node] l'IA veut %s sur ce poste :\n %s\nAutoriser ? [y/N] ", nodewire.CapLabel(m.Cap), label)
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
if sc.Scan() {
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "y")
|
|
}
|
|
return false
|
|
}
|
|
|
|
func runShell(parent context.Context, command string, timeoutSec int, dir string) string {
|
|
if strings.TrimSpace(command) == "" {
|
|
return "[erreur] commande vide"
|
|
}
|
|
if timeoutSec <= 0 {
|
|
timeoutSec = defaultTimeoutSec
|
|
}
|
|
if timeoutSec > maxTimeoutSec {
|
|
timeoutSec = maxTimeoutSec
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
|
|
defer cancel()
|
|
cmd := shellCmd(ctx, command)
|
|
if dir != "" {
|
|
if err := os.MkdirAll(dir, 0o755); err == nil {
|
|
cmd.Dir = dir
|
|
}
|
|
}
|
|
var stdout, stderr strings.Builder
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
cmd.WaitDelay = 2 * time.Second
|
|
err := cmd.Run()
|
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
|
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
|
|
}
|
|
exit := 0
|
|
if err != nil {
|
|
if ee, ok := err.(interface{ ExitCode() int }); ok {
|
|
exit = ee.ExitCode()
|
|
} else {
|
|
return fmt.Sprintf("[erreur: %v]", err)
|
|
}
|
|
}
|
|
parts := []string{fmt.Sprintf("exit: %d", exit)}
|
|
if o := tail(stdout.String(), maxOutput); o != "" {
|
|
parts = append(parts, "stdout:\n"+o)
|
|
}
|
|
if e := tail(stderr.String(), maxOutput); e != "" {
|
|
parts = append(parts, "stderr:\n"+e)
|
|
}
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
func readFile(root, path string) string {
|
|
abs, err := nodewire.ResolvePath(root, path)
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
f, err := os.Open(abs)
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
defer f.Close()
|
|
b, err := io.ReadAll(io.LimitReader(f, readMax+1))
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
if len(b) > readMax {
|
|
return string(b[:readMax]) + "\n…[tronqué]"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func writeFile(root, path, content string) string {
|
|
abs, err := nodewire.ResolvePath(root, path)
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
if d := filepath.Dir(abs); d != "" {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
return fmt.Sprintf("[ok] %s écrit (%d octets)", abs, len(content))
|
|
}
|
|
|
|
func listDir(root, path string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
path = "."
|
|
}
|
|
abs, err := nodewire.ResolvePath(root, path)
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
entries, err := os.ReadDir(abs)
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
n := e.Name()
|
|
if e.IsDir() {
|
|
n += "/"
|
|
}
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) == 0 {
|
|
return "[vide]"
|
|
}
|
|
return strings.Join(names, "\n")
|
|
}
|
|
|
|
func tail(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return string(r[len(r)-n:])
|
|
}
|