mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
v0.9.0 : poste distant accessible via ajean.link, chiffré de bout en bout (relais aveugle)
This commit is contained in:
1 parent
6d81f5c3f5
commit
1454dff2a7
16 files changed
+640
-199
No files matched your search
+52
-24
@@ -9,6 +9,7 @@
|
||||
package ajean
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -42,25 +43,48 @@ func handleNodePair(w http.ResponseWriter, r *http.Request) {
|
||||
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()),
|
||||
"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) échange un code d'appairage contre une clé
|
||||
// d'appareil. Le code est à usage unique : consommé dès qu'il sert.
|
||||
// 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.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "requête invalide"})
|
||||
if err := json.Unmarshal(plain, &pm); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "contenu du sceau"})
|
||||
return
|
||||
}
|
||||
pending, ok := loadPairPending()
|
||||
@@ -68,23 +92,26 @@ func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
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(req.Code), pending.Code) {
|
||||
if !strings.EqualFold(strings.TrimSpace(pm.Code), pending.Code) {
|
||||
sendJSON(w, 403, map[string]any{"error": "code incorrect"})
|
||||
return
|
||||
}
|
||||
// Consomme le code immédiatement (usage unique), même si la suite échoue.
|
||||
clearPairPending()
|
||||
clearPairPending() // usage unique
|
||||
|
||||
key := nodeRandHex(24)
|
||||
name := strings.TrimSpace(req.Name)
|
||||
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(req.OS),
|
||||
KeyHash: nodeHashKey(key),
|
||||
OS: strings.TrimSpace(pm.OS),
|
||||
PubHex: pub,
|
||||
Caps: pending.Caps,
|
||||
Root: pending.Root,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
@@ -94,14 +121,15 @@ func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// La clé n'est renvoyée QU'ICI, une seule fois : le poste la stocke en local.
|
||||
// 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,
|
||||
"key": key,
|
||||
"caps": node.Caps,
|
||||
"root": node.Root,
|
||||
"name": node.Name,
|
||||
"ok": true,
|
||||
"id": node.ID,
|
||||
"machine_id": machineID(),
|
||||
"caps": node.Caps,
|
||||
"root": node.Root,
|
||||
"name": node.Name,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+106
-55
@@ -7,9 +7,8 @@ package ajean
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/nathaninline/ajean/internal/nodewire"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,13 +28,14 @@ const (
|
||||
)
|
||||
|
||||
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
|
||||
// On ne garde JAMAIS la clé en clair : seulement son SHA-256. Le poste, lui,
|
||||
// conserve la clé en local (voir node_client.go).
|
||||
// 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"`
|
||||
KeyHash string `json:"key_hash"`
|
||||
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"`
|
||||
@@ -49,25 +50,30 @@ func loadNodes() []pairedNode {
|
||||
|
||||
func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
|
||||
|
||||
// findNodeByKey retrouve un poste appairé à partir de la clé d'appareil présentée
|
||||
// (comparaison à temps constant sur le hash). Renvoie nil si aucune ne matche.
|
||||
func findNodeByKey(key string) *pairedNode {
|
||||
if key == "" {
|
||||
// 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
|
||||
}
|
||||
want := nodeHashKey(key)
|
||||
nodes := loadNodes()
|
||||
for i := range nodes {
|
||||
if subtle.ConstantTimeCompare([]byte(nodes[i].KeyHash), []byte(want)) == 1 {
|
||||
if strings.ToLower(nodes[i].PubHex) == pub {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nodeHashKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
// 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).
|
||||
@@ -112,18 +118,42 @@ type nodeConn struct {
|
||||
|
||||
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, m)
|
||||
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 (
|
||||
@@ -169,16 +199,12 @@ func nodeConnected() []*nodeConn {
|
||||
|
||||
// ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
|
||||
|
||||
// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE (pas
|
||||
// derrière la clé de pilotage) : l'authentification se fait par la clé d'appareil
|
||||
// remise à l'appairage, présentée en Bearer.
|
||||
// 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) {
|
||||
key := bearerToken(r)
|
||||
pn := findNodeByKey(key)
|
||||
if pn == nil {
|
||||
http.Error(w, "poste non appairé", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
if err != nil {
|
||||
return
|
||||
@@ -186,43 +212,77 @@ func handleNodeWS(w http.ResponseWriter, r *http.Request) {
|
||||
c.SetReadLimit(-1)
|
||||
ctx := r.Context()
|
||||
|
||||
// 1er message : le hello où le poste déclare son nom/os et ce qu'il sait faire.
|
||||
// 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 hello nodeMsg
|
||||
err = wsjson.Read(helloCtx, c, &hello)
|
||||
var hp struct {
|
||||
Type string `json:"type"`
|
||||
Pub string `json:"pub"`
|
||||
}
|
||||
err = wsjson.Read(helloCtx, c, &hp)
|
||||
cancel()
|
||||
if err != nil || hello.Type != "hello" {
|
||||
_ = c.Close(websocket.StatusProtocolError, "hello attendu")
|
||||
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
|
||||
}
|
||||
caps := nodeCapIntersect(pn.Caps, hello.Caps)
|
||||
nc.name = name
|
||||
nc.slug = nodeSlug(name)
|
||||
nc.os = strings.TrimSpace(hello.OS)
|
||||
nc.caps = nodeCapIntersect(pn.Caps, hello.Caps)
|
||||
|
||||
nc := &nodeConn{
|
||||
id: pn.ID,
|
||||
slug: nodeSlug(name),
|
||||
name: name,
|
||||
os: strings.TrimSpace(hello.OS),
|
||||
caps: caps,
|
||||
root: pn.Root,
|
||||
conn: c,
|
||||
ctx: ctx,
|
||||
pending: map[string]chan string{},
|
||||
}
|
||||
nodeRegister(nc)
|
||||
defer nodeUnregister(nc)
|
||||
touchNodeSeen(pn.ID)
|
||||
|
||||
// Boucle de lecture : les seules entrées attendues sont des « result » qui
|
||||
// débloquent l'appel en attente correspondant. Le ping/keepalive est géré par
|
||||
// coder/websocket.
|
||||
// Boucle de lecture : des « result » chiffrés qui débloquent l'appel en attente.
|
||||
for {
|
||||
var m nodeMsg
|
||||
if err := wsjson.Read(ctx, c, &m); err != nil {
|
||||
m, err := nc.readMsg(ctx)
|
||||
if err != nil {
|
||||
_ = c.CloseNow()
|
||||
return
|
||||
}
|
||||
@@ -249,15 +309,6 @@ func touchNodeSeen(id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken extrait le jeton d'un en-tête « Authorization: Bearer … ».
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if s, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ package ajean
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -11,15 +12,22 @@ import (
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/nathaninline/ajean/internal/nodewire"
|
||||
)
|
||||
|
||||
// TestNodeEndToEnd exerce tout le chemin serveur : enrôlement via code, connexion
|
||||
// WebSocket, négociation des capacités (intersection propriétaire ∩ poste), et
|
||||
// routage d'un appel d'outil jusqu'au poste et retour.
|
||||
func TestNodeEndToEnd(t *testing.T) {
|
||||
// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
|
||||
// ouvert par e2eOpenSeal de l'agent → interop crypto validée), puis canal
|
||||
// CHIFFRÉ poste↔agent (hello + appel d'outil + résultat), et refus d'une
|
||||
// capacité non autorisée.
|
||||
func TestNodeE2E(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Le propriétaire n'autorise que read + shell (pas write).
|
||||
agentPub := e2ePubHex()
|
||||
if agentPub == "" {
|
||||
t.Fatal("clé publique agent indisponible")
|
||||
}
|
||||
|
||||
// Le propriétaire autorise read + shell (pas write).
|
||||
if err := savePairPending(nodePairPending{
|
||||
Code: "ABCD1234",
|
||||
Caps: []string{nodeCapRead, nodeCapShell},
|
||||
@@ -34,104 +42,97 @@ func TestNodeEndToEnd(t *testing.T) {
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// 1) Enrôlement : le code est échangé contre une clé d'appareil.
|
||||
body, _ := json.Marshal(map[string]string{"code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
|
||||
// 1) Le poste génère sa paire et SCELLE {pub, code, name, os} vers l'agent.
|
||||
priv, pub, err := nodewire.GenKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inner, _ := json.Marshal(map[string]string{"pub": pub, "code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
|
||||
blob, err := nodewire.SealTo(agentPub, inner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
|
||||
resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enr struct {
|
||||
OK bool `json:"ok"`
|
||||
Key string `json:"key"`
|
||||
Caps []string `json:"caps"`
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&enr)
|
||||
resp.Body.Close()
|
||||
if !enr.OK || enr.Key == "" {
|
||||
if !enr.OK {
|
||||
t.Fatalf("enrôlement échoué: %+v", enr)
|
||||
}
|
||||
// Le code est à usage unique : un second enrôlement doit échouer.
|
||||
resp2, _ := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
|
||||
if resp2.StatusCode == 200 {
|
||||
t.Fatal("le code d'appairage aurait dû être consommé (usage unique)")
|
||||
if findNodeByPub(pub) == nil {
|
||||
t.Fatal("la clé publique du poste n'a pas été enregistrée")
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
// 2) Le poste se connecte et sert les appels.
|
||||
// 2) Connexion WS chiffrée.
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{"Authorization": {"Bearer " + enr.Key}},
|
||||
})
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.CloseNow()
|
||||
// Le poste déclare pouvoir read/shell/write ; l'effectif sera ∩ = read/shell.
|
||||
if err := wsjson.Write(ctx, c, nodeMsg{Type: "hello", Name: "testpc", OS: "linux/amd64",
|
||||
Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}}); err != nil {
|
||||
t.Fatalf("hello: %v", err)
|
||||
|
||||
// hello_pub en clair, puis canal chiffré côté client.
|
||||
if err := wsjson.Write(ctx, c, map[string]string{"type": "hello_pub", "pub": pub}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Boucle client : répond à chaque call par "RESULT:<cap>".
|
||||
key, _ := nodewire.ChannelKey(priv, agentPub)
|
||||
ch, _ := nodewire.NewChan(key, true)
|
||||
sendEnc := func(m nodewire.Msg) { _ = wsjson.Write(ctx, c, ch.Seal(mustJSON(m))) }
|
||||
sendEnc(nodewire.Msg{Type: "hello", Name: "testpc", OS: "linux/amd64", Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}})
|
||||
|
||||
// Boucle client : répond à chaque call chiffré par "RESULT:<cap>".
|
||||
go func() {
|
||||
for {
|
||||
var m nodeMsg
|
||||
if err := wsjson.Read(context.Background(), c, &m); err != nil {
|
||||
var fr nodewire.Frame
|
||||
if err := wsjson.Read(context.Background(), c, &fr); err != nil {
|
||||
return
|
||||
}
|
||||
if m.Type == "call" {
|
||||
_ = wsjson.Write(context.Background(), c, nodeMsg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
|
||||
plain, err := ch.Open(fr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m nodewire.Msg
|
||||
if json.Unmarshal(plain, &m) == nil && m.Type == "call" {
|
||||
sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Attend l'enregistrement du poste.
|
||||
// Attend l'enregistrement.
|
||||
slug := nodeSlug("testpc")
|
||||
var nc *nodeConn
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := 0; i < 200; i++ {
|
||||
if nc = nodeGet(slug); nc != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if nc == nil {
|
||||
t.Fatal("le poste ne s'est pas enregistré")
|
||||
t.Fatal("le poste ne s'est pas enregistré (canal chiffré ?)")
|
||||
}
|
||||
|
||||
// 3) Capacités effectives = intersection : read + shell, PAS write.
|
||||
if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
|
||||
t.Fatalf("capacités effectives inattendues: %v", nc.caps)
|
||||
}
|
||||
|
||||
// 4) Un appel autorisé traverse jusqu'au poste et revient.
|
||||
// 3) Appel autorisé routé jusqu'au poste et retour, à travers le chiffrement.
|
||||
if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
|
||||
t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
|
||||
}
|
||||
|
||||
// 5) Un appel NON autorisé (write) est refusé côté serveur, sans atteindre le poste.
|
||||
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x", "content": "y"}); !strings.Contains(got, "non autorisée") {
|
||||
// 4) Appel non autorisé (write) refusé côté serveur.
|
||||
if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x"}); !strings.Contains(got, "non autorisée") {
|
||||
t.Fatalf("appel write aurait dû être refusé, obtenu %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Sélection de la cible : l'agent agit sur ce poste. La méta reflète un
|
||||
// poste connecté, et un appel shell (autorisé) est bien routé jusqu'au poste.
|
||||
if err := setAgentTargetSlug(slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, ok := nodeTargetMetaGet()
|
||||
if !ok || !meta.connected || meta.slug != slug {
|
||||
t.Fatalf("cible inattendue: ok=%v %+v", ok, meta)
|
||||
}
|
||||
if got := nodeCall(slug, nodeCapShell, map[string]any{"command": "echo hi"}); got != "RESULT:shell" {
|
||||
t.Fatalf("routage shell: attendu RESULT:shell, obtenu %q", got)
|
||||
}
|
||||
|
||||
// 7) nodeEditRemote lit puis réécrit via le poste (le client fictif renvoie
|
||||
// "RESULT:read" à la lecture → "old" introuvable, donc erreur explicite, pas
|
||||
// un plantage : on vérifie juste que le chemin read→write est emprunté).
|
||||
if got := nodeEditRemote(slug, "f.txt", "absent", "x"); !strings.Contains(got, "introuvable") {
|
||||
t.Fatalf("nodeEditRemote: attendu 'introuvable', obtenu %q", got)
|
||||
}
|
||||
setAgentTargetSlug("") // ne pas fuiter la cible sur d'autres tests
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -499,6 +499,13 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux
|
||||
return
|
||||
}
|
||||
// Poste distant : l'enrôlement (sceau anonyme) et le WebSocket (canal
|
||||
// chiffré poste↔agent) sont E2E — le relais ne voit que de l'opaque. On les
|
||||
// laisse donc passer, comme /api/e2e/*, sans casser la boîte noire.
|
||||
if p == "/api/node/enroll" || p == "/api/node/ws" {
|
||||
web.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
http.Error(w, "via le relais : seuls les endpoints chiffrés /api/e2e/* sont autorisés (boîte noire)", http.StatusForbidden)
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.8.9"
|
||||
const Version = "0.9.0"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
|
||||
func Main() {
|
||||
|
||||
@@ -1723,8 +1723,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
|
||||
<div class="node-code" id="node-pair-code">——</div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">
|
||||
Sur le PC à piloter, installe AJEAN puis lance :
|
||||
Sur le PC à piloter, installe <b>ajean-remote</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
|
||||
<pre class="node-cmd" id="node-pair-cmd"></pre>
|
||||
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
|
||||
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
|
||||
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
|
||||
</div>
|
||||
</div>
|
||||
@@ -5504,8 +5506,17 @@ async function genNodeCode(){
|
||||
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
|
||||
const allow = caps.join(',');
|
||||
const rootArg = root ? (' --root "'+root+'"') : '';
|
||||
document.getElementById('node-pair-cmd').textContent =
|
||||
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
|
||||
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
|
||||
// scelle vers elle), --machine = quelle machine joindre.
|
||||
const remote = 'ajean-remote install https://ajean.link --machine '+r.machine+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
|
||||
const lan = 'ajean-remote install '+location.origin+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
document.getElementById('node-pair-cmd').textContent = remote;
|
||||
const lanEl = document.getElementById('node-pair-cmd-lan');
|
||||
if(lanEl) lanEl.textContent = lan;
|
||||
document.getElementById('node-pair-out').style.display = '';
|
||||
loadNode();
|
||||
}
|
||||
|
||||
@@ -567,8 +567,10 @@
|
||||
<div class="subhead" style="margin-top:6px">Code d'appairage</div>
|
||||
<div class="node-code" id="node-pair-code">——</div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5">
|
||||
Sur le PC à piloter, installe AJEAN puis lance :
|
||||
Sur le PC à piloter, installe <b>ajean-remote</b> (ajean.link/download) puis lance — <b>accès à distance</b>, chiffré de bout en bout :
|
||||
<pre class="node-cmd" id="node-pair-cmd"></pre>
|
||||
<span style="opacity:.8">Ou en <b>réseau local</b> (connexion directe) :</span>
|
||||
<pre class="node-cmd" id="node-pair-cmd-lan"></pre>
|
||||
Le code expire dans <span id="node-pair-ttl">10</span> min et ne sert qu'une fois.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,8 +112,17 @@ async function genNodeCode(){
|
||||
document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10;
|
||||
const allow = caps.join(',');
|
||||
const rootArg = root ? (' --root "'+root+'"') : '';
|
||||
document.getElementById('node-pair-cmd').textContent =
|
||||
'ajean-remote install '+location.origin+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en
|
||||
// bout, le relais reste aveugle. --key = clé publique de l'agent (le poste
|
||||
// scelle vers elle), --machine = quelle machine joindre.
|
||||
const remote = 'ajean-remote install https://ajean.link --machine '+r.machine+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
// Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais).
|
||||
const lan = 'ajean-remote install '+location.origin+
|
||||
' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg;
|
||||
document.getElementById('node-pair-cmd').textContent = remote;
|
||||
const lanEl = document.getElementById('node-pair-cmd-lan');
|
||||
if(lanEl) lanEl.textContent = lan;
|
||||
document.getElementById('node-pair-out').style.display = '';
|
||||
loadNode();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user