Files
Loki de6551a153 Rebaptise AJEAN en Loki (fork, lignée conservée)
- 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.
2026-08-14 21:33:15 +00:00

254 lines
7.7 KiB
Go

// node_api.go — endpoints HTTP du poste distant.
//
// Deux familles :
// - GESTION (propriétaire, derrière la clé de pilotage) : générer un code
// d'appairage, lister/configurer/révoquer les postes.
// - ENRÔLEMENT (public) : le poste échange un code d'appairage à usage unique
// contre sa clé d'appareil. Authentifié par le code, pas par la clé de
// pilotage — que le poste ne possède pas.
package loki
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/coder/websocket"
)
// handleNodePair (authed) génère un code d'appairage à usage unique valable
// nodePairCodeTTL. Le propriétaire choisit les capacités accordées et le dossier
// racine ; le poste ne pourra jamais dépasser ça.
func handleNodePair(w http.ResponseWriter, r *http.Request) {
var req struct {
Caps []string `json:"caps"`
Root string `json:"root"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
caps := nodeSanitizeCaps(req.Caps)
if len(caps) == 0 {
// Défaut prudent : lecture seule. Le propriétaire élargit ensuite.
caps = []string{nodeCapRead, nodeCapList}
}
p := nodePairPending{
Code: strings.ToUpper(nodeRandHex(4)), // 8 caractères hex lisibles
Caps: caps,
Root: strings.TrimSpace(req.Root),
Expires: time.Now().Add(nodePairCodeTTL).Unix(),
}
if err := savePairPending(p); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{
"ok": true,
"code": p.Code,
"caps": p.Caps,
"root": p.Root,
"expires": p.Expires,
"ttl_min": int(nodePairCodeTTL.Minutes()),
"machine": machineID(), // pour l'accès via ajean.link (/node/<machine>/)
"agent_pub": e2ePubHex(), // clé publique de l'agent (le poste scelle vers elle)
"fingerprint": e2eFingerprint(), // empreinte, ancre de confiance
})
}
// handleNodeEnroll (PUBLIC) enrôle un poste. Le corps est un SCEAU anonyme vers
// la clé publique de l'agent contenant {pub, code, name, os} : le relais ne peut
// ni l'ouvrir, ni voir le code, ni la clé publique du poste. Si le code matche,
// la clé publique est enregistrée comme identité autorisée du poste.
func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
var req struct {
Sealed string `json:"sealed"` // base64(ephPub||nonce||ct) vers la clé agent
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sealed == "" {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
blob, err := base64.StdEncoding.DecodeString(req.Sealed)
if err != nil {
sendJSON(w, 400, map[string]any{"error": "format du sceau"})
return
}
plain, err := e2eOpenSeal(blob)
if err != nil {
sendJSON(w, 400, map[string]any{"error": "sceau invalide"})
return
}
var pm struct {
Pub string `json:"pub"`
Code string `json:"code"`
Name string `json:"name"`
OS string `json:"os"`
}
if err := json.Unmarshal(plain, &pm); err != nil {
sendJSON(w, 400, map[string]any{"error": "contenu du sceau"})
return
}
pending, ok := loadPairPending()
if !ok {
sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
return
}
if !strings.EqualFold(strings.TrimSpace(pm.Code), pending.Code) {
sendJSON(w, 403, map[string]any{"error": "code incorrect"})
return
}
clearPairPending() // usage unique
pub := strings.ToLower(strings.TrimSpace(pm.Pub))
if len(pub) != 64 {
sendJSON(w, 400, map[string]any{"error": "clé publique du poste invalide"})
return
}
name := strings.TrimSpace(pm.Name)
if name == "" {
name = "poste"
}
node := pairedNode{
ID: nodeRandHex(8),
Name: name,
OS: strings.TrimSpace(pm.OS),
PubHex: pub,
Caps: pending.Caps,
Root: pending.Root,
CreatedAt: time.Now().Unix(),
}
nodes := append(loadNodes(), node)
if err := saveNodes(nodes); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// Réponse en clair : elle ne contient AUCUN secret (le secret, la clé privée,
// n'a jamais quitté le poste). machine_id sert au poste pour l'URL relais.
sendJSON(w, 200, map[string]any{
"ok": true,
"id": node.ID,
"machine_id": machineID(),
"caps": node.Caps,
"root": node.Root,
"name": node.Name,
})
}
// handleNodes (authed) liste les postes appairés avec leur état de connexion.
func handleNodes(w http.ResponseWriter, r *http.Request) {
connected := map[string]bool{}
for _, nc := range nodeConnected() {
connected[nc.id] = true
}
nodes := loadNodes()
out := make([]map[string]any, 0, len(nodes))
for _, n := range nodes {
out = append(out, map[string]any{
"id": n.ID,
"name": n.Name,
"slug": nodeSlug(n.Name),
"os": n.OS,
"caps": n.Caps,
"root": n.Root,
"created_at": n.CreatedAt,
"last_seen": n.LastSeen,
"connected": connected[n.ID],
})
}
_, pending := loadPairPending()
sendJSON(w, 200, map[string]any{"ok": true, "nodes": out, "all_caps": nodeAllCaps, "pairing": pending, "target": agentTargetSlug()})
}
// handleNodeTarget (authed) choisit la machine sur laquelle l'agent agit :
// slug d'un poste, ou "" pour le serveur local.
func handleNodeTarget(w http.ResponseWriter, r *http.Request) {
var req struct {
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
if err := setAgentTargetSlug(strings.TrimSpace(req.Slug)); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "target": agentTargetSlug()})
}
// handleNodeCaps (authed) met à jour les capacités autorisées et/ou le dossier
// racine d'un poste. Prend effet à la prochaine (re)connexion du poste.
func handleNodeCaps(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Caps []string `json:"caps"`
Root *string `json:"root"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
nodes := loadNodes()
found := false
for i := range nodes {
if nodes[i].ID == req.ID {
nodes[i].Caps = nodeSanitizeCaps(req.Caps)
if req.Root != nil {
nodes[i].Root = strings.TrimSpace(*req.Root)
}
found = true
break
}
}
if !found {
sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
return
}
if err := saveNodes(nodes); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
// Coupe la session en cours pour que les nouvelles capacités s'appliquent
// (le poste se reconnecte et re-négocie l'intersection).
for _, nc := range nodeConnected() {
if nc.id == req.ID {
_ = nc.conn.Close(websocket.StatusNormalClosure, "capacités mises à jour")
}
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleNodeRevoke (authed) révoque un poste : oublie sa clé et le déconnecte.
func handleNodeRevoke(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
return
}
nodes := loadNodes()
kept := nodes[:0]
removed := false
for _, n := range nodes {
if n.ID == req.ID {
removed = true
continue
}
kept = append(kept, n)
}
if !removed {
sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
return
}
if err := saveNodes(kept); err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
}
for _, nc := range nodeConnected() {
if nc.id == req.ID {
_ = nc.conn.Close(websocket.StatusPolicyViolation, "poste révoqué")
}
}
sendJSON(w, 200, map[string]any{"ok": true})
}