Files
Loki/internal/loki/node_server.go
T
MichaelandClaude Opus 5 c45ba615fa Démarrage, captures, postes distants : correctifs portés d'AJEAN
- 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>
2026-09-07 22:33:55 +02:00

467 lines
14 KiB
Go

// node_server.go — côté SERVEUR du poste distant : appairage, endpoint
// WebSocket, registre des postes connectés, et exposition de leurs outils à
// l'agent. Le serveur n'exécute JAMAIS rien lui-même ici : il route une demande
// d'outil vers le poste concerné et attend sa réponse.
package loki
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/R0m1k3/Loki/internal/nodewire"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
const (
nodePairCodeTTL = 10 * time.Minute
nodeCallTimeout = 5 * time.Minute // un shell distant peut être long
nodeHelloWait = 15 * time.Second
// nodePingInterval : cadence du keepalive WebSocket poste↔serveur. 25 s tient
// sous les délais d'inactivité usuels des proxys et relais (~60-100 s).
nodePingInterval = 25 * time.Second
// nodePingTimeout : au-delà, le poste ne répond plus — on ferme au lieu de
// laisser une connexion morte occuper le registre.
nodePingTimeout = 10 * time.Second
)
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
// L'identité du poste = sa CLÉ PUBLIQUE X25519 (PubHex). Il n'y a plus de « clé
// d'appareil » partagée : le poste prouve son identité en sachant chiffrer le
// canal (ECDH avec sa clé privée), ce que le relais ne peut pas reproduire.
type pairedNode struct {
ID string `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
PubHex string `json:"pub"` // clé publique X25519 du poste (son identité)
Caps []string `json:"caps"` // capacités AUTORISÉES par le propriétaire
Root string `json:"root,omitempty"`
CreatedAt int64 `json:"created_at"`
LastSeen int64 `json:"last_seen,omitempty"`
}
func loadNodes() []pairedNode {
var l []pairedNode
getJSON(bkState, "nodes", &l)
return l
}
func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
// findNodeByPub retrouve un poste appairé à partir de sa clé publique.
func findNodeByPub(pub string) *pairedNode {
pub = strings.ToLower(strings.TrimSpace(pub))
if pub == "" {
return nil
}
nodes := loadNodes()
for i := range nodes {
if strings.ToLower(nodes[i].PubHex) == pub {
return &nodes[i]
}
}
return nil
}
// agentPrivHex renvoie la clé privée X25519 de l'agent en hex (pour dériver la
// clé de canal poste↔agent via nodewire). Même clé que le canal navigateur, mais
// domaine séparé par la constante de dérivation.
func agentPrivHex() (string, error) {
k, err := e2ePrivateKey()
if err != nil {
return "", err
}
return hex.EncodeToString(k.Bytes()), nil
}
// nodeRandHex renvoie n octets aléatoires en hexadécimal (2n caractères).
func nodeRandHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ─── Code d'appairage (usage unique, TTL court) ────────────────────────────
type nodePairPending struct {
Code string `json:"code"`
Caps []string `json:"caps"` // capacités que ce code accordera
Root string `json:"root,omitempty"`
Expires int64 `json:"expires"`
}
func loadPairPending() (nodePairPending, bool) {
var p nodePairPending
if !getJSON(bkState, "node_pair", &p) || p.Code == "" {
return nodePairPending{}, false
}
if time.Now().Unix() > p.Expires {
return nodePairPending{}, false
}
return p, true
}
func savePairPending(p nodePairPending) error { return putJSON(bkState, "node_pair", p) }
func clearPairPending() { _ = putJSON(bkState, "node_pair", nodePairPending{}) }
// ─── Registre des postes CONNECTÉS (en mémoire) ────────────────────────────
type nodeConn struct {
id string
slug string
name string
os string
caps []string // capacités EFFECTIVES (autorisées ∩ déclarées)
root string
conn *websocket.Conn
ctx context.Context
ch *nodewire.Chan // canal chiffré poste↔agent (relais aveugle)
writeMu sync.Mutex
mu sync.Mutex
pending map[string]chan string
}
// send chiffre le message et l'envoie en un frame opaque : le relais ne voit
// que du ciphertext.
func (nc *nodeConn) send(m nodeMsg) error {
nc.writeMu.Lock()
defer nc.writeMu.Unlock()
raw, err := json.Marshal(m)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second)
defer cancel()
return wsjson.Write(ctx, nc.conn, nc.ch.Seal(raw))
}
// readMsg lit un frame chiffré et le déchiffre en nodeMsg.
func (nc *nodeConn) readMsg(ctx context.Context) (nodeMsg, error) {
var fr nodewire.Frame
if err := wsjson.Read(ctx, nc.conn, &fr); err != nil {
return nodeMsg{}, err
}
plain, err := nc.ch.Open(fr)
if err != nil {
return nodeMsg{}, err
}
var m nodeMsg
if err := json.Unmarshal(plain, &m); err != nil {
return nodeMsg{}, err
}
return m, nil
}
var (
nodeRegMu sync.Mutex
nodeReg = map[string]*nodeConn{} // slug → connexion vivante
)
func nodeRegister(nc *nodeConn) {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
// Un même slug déjà connecté (reconnexion, ou deux postes de même nom) : on
// ferme l'ancien pour éviter deux postes indiscernables sous le même outil.
if old, ok := nodeReg[nc.slug]; ok {
_ = old.conn.Close(websocket.StatusPolicyViolation, "remplacé par une nouvelle session")
}
nodeReg[nc.slug] = nc
}
func nodeUnregister(nc *nodeConn) {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
if cur, ok := nodeReg[nc.slug]; ok && cur == nc {
delete(nodeReg, nc.slug)
}
}
func nodeGet(slug string) *nodeConn {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
return nodeReg[slug]
}
func nodeConnected() []*nodeConn {
nodeRegMu.Lock()
defer nodeRegMu.Unlock()
out := make([]*nodeConn, 0, len(nodeReg))
for _, nc := range nodeReg {
out = append(out, nc)
}
sort.Slice(out, func(i, j int) bool { return out[i].slug < out[j].slug })
return out
}
// ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE : elle
// peut arriver en direct (LAN) OU tunnelée par le relais ajean.link. Dans les
// deux cas le canal est chiffré de BOUT EN BOUT (poste↔agent) : le relais ne voit
// que de l'opaque. L'authentification = la capacité du poste à chiffrer avec la
// clé de canal, que seul le détenteur de la clé privée appairée peut calculer.
func handleNodeWS(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
c.SetReadLimit(-1)
ctx := r.Context()
// Poignée de main : 1er frame EN CLAIR = la clé publique du poste (elle est
// publique, aucun secret). Elle nous dit QUI se connecte et permet de dériver
// la clé de canal. Le poste ne pourra chiffrer/déchiffrer que s'il détient la
// clé privée correspondante — c'est ça, l'authentification.
helloCtx, cancel := context.WithTimeout(ctx, nodeHelloWait)
var hp struct {
Type string `json:"type"`
Pub string `json:"pub"`
}
err = wsjson.Read(helloCtx, c, &hp)
cancel()
if err != nil || hp.Type != "hello_pub" || hp.Pub == "" {
_ = c.Close(websocket.StatusProtocolError, "hello_pub attendu")
return
}
pn := findNodeByPub(hp.Pub)
if pn == nil {
_ = c.Close(websocket.StatusPolicyViolation, "poste non appairé")
return
}
privHex, err := agentPrivHex()
if err != nil {
_ = c.Close(websocket.StatusInternalError, "clé agent indisponible")
return
}
key, err := nodewire.ChannelKey(privHex, hp.Pub)
if err != nil {
_ = c.Close(websocket.StatusInternalError, "canal")
return
}
ch, err := nodewire.NewChan(key, false) // agent = côté d'envoi 2
if err != nil {
_ = c.Close(websocket.StatusInternalError, "canal")
return
}
nc := &nodeConn{
id: pn.ID,
conn: c,
ctx: ctx,
ch: ch,
pending: map[string]chan string{},
root: pn.Root,
}
// 1er frame CHIFFRÉ : le hello (nom/os/capacités déclarées). S'il ne déchiffre
// pas, le poste n'a pas la bonne clé privée → connexion refusée.
helloCtx2, cancel2 := context.WithTimeout(ctx, nodeHelloWait)
hello, err := nc.readMsg(helloCtx2)
cancel2()
if err != nil || hello.Type != "hello" {
_ = c.Close(websocket.StatusPolicyViolation, "hello chiffré attendu")
return
}
name := strings.TrimSpace(hello.Name)
if name == "" {
name = pn.Name
}
nc.name = name
nc.slug = nodeSlug(name)
nc.os = strings.TrimSpace(hello.OS)
nc.caps = nodeCapIntersect(pn.Caps, hello.Caps)
nodeRegister(nc)
defer nodeUnregister(nc)
touchNodeSeen(pn.ID)
// Keepalive : un ping WebSocket périodique garde la connexion vivante à travers
// les intermédiaires qui coupent les canaux INACTIFS (relais, Caddy, Cloudflare :
// ~60-100 s sans trafic). Sans lui, un poste au repos — l'IA ne le sollicite pas
// en permanence — était déconnecté en boucle puis reconnecté après backoff.
// coder/websocket sérialise Ping avec les écritures de données en interne : pas
// de course avec nc.send. Un ping sans réponse ferme la connexion, ce qui
// débloque le Read de la boucle ci-dessous.
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
}
}
}
}()
// Boucle de lecture : des « result » chiffrés qui débloquent l'appel en attente.
for {
m, err := nc.readMsg(ctx)
if err != nil {
_ = c.CloseNow()
return
}
if m.Type == "result" {
nc.mu.Lock()
ch := nc.pending[m.ID]
delete(nc.pending, m.ID)
nc.mu.Unlock()
if ch != nil {
ch <- m.Result
}
}
}
}
func touchNodeSeen(id string) {
nodes := loadNodes()
for i := range nodes {
if nodes[i].ID == id {
nodes[i].LastSeen = time.Now().Unix()
_ = saveNodes(nodes)
return
}
}
}
// ─── Appel d'un outil de poste (routage serveur→poste→serveur) ─────────────
// nodeCall envoie une demande d'exécution au poste et attend son résultat. Toute
// la sécurité côté serveur tient ici : on ne route que vers un poste connecté et
// une capacité effectivement autorisée.
func nodeCall(slug, cap string, args map[string]any) string {
nc := nodeGet(slug)
if nc == nil {
return "[erreur] poste « " + slug + " » déconnecté"
}
if !nodeCapAllowed(nc.caps, cap) {
return "[erreur] capacité « " + cap + " » non autorisée sur ce poste"
}
id := nodeRandHex(8)
ch := make(chan string, 1)
nc.mu.Lock()
nc.pending[id] = ch
nc.mu.Unlock()
defer func() {
nc.mu.Lock()
delete(nc.pending, id)
nc.mu.Unlock()
}()
if err := nc.send(nodeMsg{Type: "call", ID: id, Cap: cap, Args: args}); err != nil {
return "[erreur] envoi au poste impossible: " + err.Error()
}
select {
case res := <-ch:
return res
case <-time.After(nodeCallTimeout):
return "[timeout] le poste n'a pas répondu à temps"
case <-nc.ctx.Done():
return "[erreur] poste déconnecté pendant l'appel"
}
}
// nodeEditRemote applique une édition (old→new, old unique) à un fichier d'un
// poste : on le lit, on remplace localement, on réécrit — via les capacités
// read + write du poste. Reproduit fileEdit mais à distance.
func nodeEditRemote(slug, path, oldText, newText string) string {
if oldText == "" {
return "[erreur] old vide"
}
content := nodeCall(slug, nodeCapRead, map[string]any{"path": path})
if isNodeErr(content) {
return content
}
n := strings.Count(content, oldText)
if n == 0 {
if newText != "" && strings.Contains(content, newText) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
}
if n > 1 {
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(content, oldText, newText, 1)
return nodeCall(slug, nodeCapWrite, map[string]any{"path": path, "content": updated})
}
// isNodeErr indique si un résultat d'appel de poste est un échec (à ne pas
// traiter comme du contenu utile).
func isNodeErr(s string) bool {
return strings.HasPrefix(s, "[erreur]") || strings.HasPrefix(s, "[refusé]") || strings.HasPrefix(s, "[timeout]")
}
// ─── Cible d'exécution de l'agent (quel PC l'IA pilote) ────────────────────
// agentTargetSlug renvoie le slug du poste sur lequel l'agent agit, ou "" pour
// le serveur local (comportement historique). Persisté dans bkState.
func agentTargetSlug() string { return getStr(bkState, "agent_target") }
func setAgentTargetSlug(slug string) error { return putStr(bkState, "agent_target", slug) }
// nodeTargetMeta décrit la cible d'exécution courante pour le prompt système.
type nodeTargetMeta struct {
slug, name, os, root string
connected bool
}
// nodeTargetMetaGet renvoie la cible sélectionnée (ok=false si c'est le serveur
// local). Complète nom/os/dossier depuis le registre si le poste est connecté,
// sinon depuis l'enregistrement d'appairage.
func nodeTargetMetaGet() (nodeTargetMeta, bool) {
slug := agentTargetSlug()
if slug == "" {
return nodeTargetMeta{}, false
}
m := nodeTargetMeta{slug: slug}
if nc := nodeGet(slug); nc != nil {
m.name, m.os, m.root, m.connected = nc.name, nc.os, nc.root, true
return m, true
}
for _, n := range loadNodes() {
if nodeSlug(n.Name) == slug {
m.name, m.os, m.root = n.Name, n.OS, n.Root
break
}
}
if m.name == "" {
m.name = slug
}
return m, true
}
// agentTargetShellName renvoie le shell de la MACHINE CIBLE : cmd.exe si le poste
// ciblé tourne sous Windows, bash sinon. Sans cible → le shell du serveur local.
// Indispensable : le serveur peut être sous Linux (bash) alors que le poste est
// sous Windows (cmd.exe) — annoncer le mauvais shell fait écrire au modèle une
// syntaxe que la machine cible ne comprend pas.
func agentTargetShellName() string {
if tgt, ok := nodeTargetMetaGet(); ok {
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
return "cmd.exe"
}
return "bash"
}
return shellName()
}