mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki) - LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ; units loki-engine / loki-ui ; binaire et aide CLI - updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les binaires AJEAN amont) Conservé à l'identique : le domaine ajean.link (service de tunnel amont), les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et LICENSE (historique et licence de l'amont). go build/vet/test : verts.
433 lines
13 KiB
Go
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/coder/websocket"
|
|
"github.com/coder/websocket/wsjson"
|
|
"github.com/R0m1k3/Loki/internal/nodewire"
|
|
)
|
|
|
|
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()
|
|
}
|