Files
Loki/internal/loki/node_server.go
T
Loki 6c4a8a3cf5 Supervision du moteur sans systemd (conteneur Docker)
Nouveau sys_service_container.go : supervision par fichier PID portée du mode
utilisateur macOS (Setsid, SIGTERM sur le groupe puis SIGKILL, log fichier).
sys_service_linux.go bascule automatiquement quand /run/systemd/system est
absent ou que LOKI_CONTAINER=1 ; une install systemd classique est inchangée.
Le tunnel ajean.link suit la même logique (uiServiceCtl / uiServiceActive).

Sans ce repli, changer de modèle depuis l'UI (serviceAction restart)
échouait sur un systemctl absent — le conteneur était inutilisable.

Testé sans systemd : start / status / restart / stop, PID suivi,
processus enfant arrêté avec le groupe. build linux+darwin+windows OK,
go vet + go test verts.
2026-08-14 21:37:51 +00:00

433 lines
13 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
)
// 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)
// 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()
}