mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 18:58:43 +02:00
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.
This commit is contained in:
157 files changed
+928
-928
No files matched your search
@@ -0,0 +1,253 @@
|
||||
// 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})
|
||||
}
|
||||
Reference in new issue
Block a user