mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 21:48:47 +02:00
v0.2.6 : heartbeat SSE anti-coupure (agents) + jean link en service (start/stop/restart/code)
- fix "TypeError: Load failed" en mode agent : heartbeat SSE (: ping /15s) pour que les longs silences (exécution d'outil) ne soient pas coupés en transit - jean link devient le pilote du service (comme jean) ; worker = jean link serve - jean link code : codes d'appairage à la demande, usage unique, TTL 10 min (store fichier haché, partagé entre le CLI et le worker) - e2eauth.go ajouté au suivi ; README + aide mis à jour
This commit is contained in:
1 parent
949e261538
commit
97fbed8de2
6 files changed
+549
-34
No files matched your search
@@ -114,7 +114,9 @@ Interaction :
|
|||||||
web [PORT] interface web (défaut :8090)
|
web [PORT] interface web (défaut :8090)
|
||||||
|
|
||||||
Accès distant (ajean.link) :
|
Accès distant (ajean.link) :
|
||||||
link <token> connecte ce Jean au relais (accès web chiffré E2E ; API OpenAI en option)
|
link [token] démarre le lien au relais en arrière-plan (token = 1re fois / pour le changer)
|
||||||
|
link restart | stop redémarre / arrête le service de lien
|
||||||
|
link code génère un code d'appairage (valable 10 min, usage unique) pour le portail
|
||||||
link status | logout état du lien / oublier le token
|
link status | logout état du lien / oublier le token
|
||||||
|
|
||||||
Outils côté LLM :
|
Outils côté LLM :
|
||||||
@@ -201,10 +203,12 @@ La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilota
|
|||||||
Plutôt que d'exposer un port, `jean link` ouvre une connexion **sortante** vers le relais [ajean.link](https://ajean.link) : ton serveur reste injoignable depuis l'extérieur, mais tu y accèdes quand même depuis n'importe où — idéal derrière une box ou en CGNAT.
|
Plutôt que d'exposer un port, `jean link` ouvre une connexion **sortante** vers le relais [ajean.link](https://ajean.link) : ton serveur reste injoignable depuis l'extérieur, mais tu y accèdes quand même depuis n'importe où — idéal derrière une box ou en CGNAT.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
jean web # l'UI doit tourner localement
|
jean link <token> # token fourni sur ajean.link ; démarre le lien en arrière-plan
|
||||||
jean link <token> # token fourni sur ajean.link
|
jean link code # un code d'appairage à saisir une fois dans le portail
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`jean link` tourne comme un **service** (au même titre que `jean` lui-même) : la commande rend la main aussitôt, le lien continue en tâche de fond. Gestion : `jean link restart`, `jean link stop`, `jean link status`. (Pas besoin de `jean web` : l'UI est servie dans le tunnel.)
|
||||||
|
|
||||||
Une fois lié, tu retrouves depuis le portail :
|
Une fois lié, tu retrouves depuis le portail :
|
||||||
- l'**interface web** de ton serveur, à distance, avec un **chat chiffré de bout en bout** ;
|
- l'**interface web** de ton serveur, à distance, avec un **chat chiffré de bout en bout** ;
|
||||||
- la gestion de **plusieurs serveurs** et d'**agents** depuis un tableau de bord ;
|
- la gestion de **plusieurs serveurs** et d'**agents** depuis un tableau de bord ;
|
||||||
@@ -217,7 +221,8 @@ C'est un service optionnel et payant ; tout le reste de Jean est et restera open
|
|||||||
`jean link` est conçu pour que le relais ajean.link soit un **pur tube aveugle** : il transporte tes données mais ne peut pas les lire.
|
`jean link` est conçu pour que le relais ajean.link soit un **pur tube aveugle** : il transporte tes données mais ne peut pas les lire.
|
||||||
|
|
||||||
- **Chat chiffré de bout en bout.** Le chat entre ton navigateur et ton serveur Jean est chiffré (X25519 + AES-GCM) : le relais ne voit ni tes prompts ni les réponses, seulement de l'opaque. La clé est dérivée de ton mot de passe (protocole **OPAQUE**) et ne quitte jamais ton navigateur.
|
- **Chat chiffré de bout en bout.** Le chat entre ton navigateur et ton serveur Jean est chiffré (X25519 + AES-GCM) : le relais ne voit ni tes prompts ni les réponses, seulement de l'opaque. La clé est dérivée de ton mot de passe (protocole **OPAQUE**) et ne quitte jamais ton navigateur.
|
||||||
- **Empreinte vérifiée.** Au premier `jean link`, Jean affiche une empreinte de la clé de la machine, à confirmer une fois dans le portail — ça défait toute tentative d'interception par le relais.
|
- **Empreinte vérifiée.** `jean link` affiche une empreinte de la clé de la machine, à confirmer une fois dans le portail — ça défait toute tentative d'interception par le relais.
|
||||||
|
- **Appairage authentifié.** En plus de l'empreinte, tu autorises ton navigateur avec un **code d'appairage** (`jean link code`) : à usage unique et valable 10 min, il garantit que seul *ton* navigateur peut piloter le serveur — même un relais compromis ne peut pas forger de commande.
|
||||||
- **Aucun chat en clair par le relais.** L'ancien chemin en clair est refusé à travers le tunnel ; seul le chemin chiffré transporte du contenu.
|
- **Aucun chat en clair par le relais.** L'ancien chemin en clair est refusé à travers le tunnel ; seul le chemin chiffré transporte du contenu.
|
||||||
- **Code servi hors du relais.** Le portail web est livré par une origine indépendante (GitHub Pages), pas par le relais — donc même compromis, le relais ne peut pas injecter de code piégé pour voler ta clé.
|
- **Code servi hors du relais.** Le portail web est livré par une origine indépendante (GitHub Pages), pas par le relais — donc même compromis, le relais ne peut pas injecter de code piégé pour voler ta clé.
|
||||||
|
|
||||||
@@ -227,10 +232,10 @@ C'est un service optionnel et payant ; tout le reste de Jean est et restera open
|
|||||||
|
|
||||||
Pour brancher des outils tiers (OpenCode, etc.), Jean peut exposer un endpoint compatible OpenAI (`https://ajean.link/oai/<machine>/v1`, clé = une clé de liaison du compte). Mais ces outils ne font pas le chiffrement navigateur : ce flux transiterait **en clair** par le relais. Il est donc **désactivé par défaut**.
|
Pour brancher des outils tiers (OpenCode, etc.), Jean peut exposer un endpoint compatible OpenAI (`https://ajean.link/oai/<machine>/v1`, clé = une clé de liaison du compte). Mais ces outils ne font pas le chiffrement navigateur : ce flux transiterait **en clair** par le relais. Il est donc **désactivé par défaut**.
|
||||||
|
|
||||||
Côté **ton serveur**, tu l'autorises en lançant `jean link` avec cette variable :
|
Côté **ton serveur**, tu l'autorises avec la variable `JEAN_LINK_ALLOW_OAI=1`. Comme le lien tourne en service, place-la dans l'environnement du service (et non en préfixe d'une commande), par ex. dans `/etc/default/jean` ou via `systemctl edit jean-link`, puis :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JEAN_LINK_ALLOW_OAI=1 jean link <token>
|
jean link restart
|
||||||
```
|
```
|
||||||
|
|
||||||
Sans elle, ton agent refuse l'endpoint (`403`). L'accès `/oai` à travers **ajean.link** dépend ensuite du service (le relais est la partie hébergée d'ajean.link, que tu ne gères pas) : la fonctionnalité doit y être ouverte pour ton compte. Assume le compromis avant de l'activer — pour ce canal, le relais voit le trafic en clair.
|
Sans elle, ton agent refuse l'endpoint (`403`). L'accès `/oai` à travers **ajean.link** dépend ensuite du service (le relais est la partie hébergée d'ajean.link, que tu ne gères pas) : la fonctionnalité doit y être ouverte pour ton compte. Assume le compromis avant de l'activer — pour ce canal, le relais voit le trafic en clair.
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
|
mu, stop := sseHeartbeat(w, flusher)
|
||||||
|
defer stop()
|
||||||
emit := func(obj map[string]any) bool {
|
emit := func(obj map[string]any) bool {
|
||||||
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
||||||
nonce := make([]byte, 12)
|
nonce := make([]byte, 12)
|
||||||
@@ -167,6 +169,8 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
|
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
|
if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// e2eauth.go — authentification MUTUELLE + anti-rejeu du canal chiffré.
|
||||||
|
//
|
||||||
|
// Le scellé anonyme (boîte scellée) garantit la confidentialité mais PAS
|
||||||
|
// l'authenticité : n'importe qui connaissant la clé publique de l'agent (dont un
|
||||||
|
// relais compromis) peut fabriquer une enveloppe. Ici on ferme ce trou.
|
||||||
|
//
|
||||||
|
// Modèle : l'utilisateur a une identité X25519 dérivée de façon déterministe de son
|
||||||
|
// mot de passe (exportKey OPAQUE → racine R → uPriv) — le relais ne connaît pas R,
|
||||||
|
// donc ne peut PAS reproduire cette identité. La clé publique uPub est APPAIRÉE une
|
||||||
|
// fois à l'agent via un code affiché par « jean link » (canal hors-bande : le log du
|
||||||
|
// serveur, que le relais ne voit pas ; le code voyage scellé vers l'agent). Ensuite,
|
||||||
|
// chaque requête est chiffrée avec K = SHA256(ECDH(uPriv, agentPriv) || "authchan"),
|
||||||
|
// liée à uPub+horodatage (AAD) et protégée contre le rejeu. Seul le vrai utilisateur
|
||||||
|
// peut produire une requête valide ; le relais est totalement verrouillé.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fenêtre d'acceptation de l'horodatage (anti-rejeu) : ±90 s pour tolérer une
|
||||||
|
// dérive d'horloge raisonnable entre l'appareil et le serveur.
|
||||||
|
const e2eAuthWindowMs = 90_000
|
||||||
|
|
||||||
|
// ---- Identités utilisateur appairées ---------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
authOnce sync.Once
|
||||||
|
authMu sync.Mutex
|
||||||
|
authSet = map[string]bool{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func authUsersPath() string { return filepath.Join(JeanHome(), ".authorized_users") }
|
||||||
|
|
||||||
|
func loadAuthUsers() {
|
||||||
|
authOnce.Do(func() {
|
||||||
|
b, err := os.ReadFile(authUsersPath())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
h := strings.ToLower(strings.TrimSpace(line))
|
||||||
|
if isHexPub(h) {
|
||||||
|
authSet[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexPub(h string) bool {
|
||||||
|
if len(h) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := hex.DecodeString(h)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuthorizedUser(uPubHex string) bool {
|
||||||
|
loadAuthUsers()
|
||||||
|
authMu.Lock()
|
||||||
|
defer authMu.Unlock()
|
||||||
|
return authSet[strings.ToLower(uPubHex)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeUser(uPubHex string) error {
|
||||||
|
uPubHex = strings.ToLower(strings.TrimSpace(uPubHex))
|
||||||
|
if !isHexPub(uPubHex) {
|
||||||
|
return fmt.Errorf("clé publique invalide")
|
||||||
|
}
|
||||||
|
loadAuthUsers()
|
||||||
|
authMu.Lock()
|
||||||
|
defer authMu.Unlock()
|
||||||
|
if authSet[uPubHex] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
authSet[uPubHex] = true
|
||||||
|
_ = os.MkdirAll(JeanHome(), 0o755)
|
||||||
|
f, err := os.OpenFile(authUsersPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.WriteString(uPubHex + "\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Codes d'appairage : à la demande, usage unique, TTL 10 min --------------
|
||||||
|
//
|
||||||
|
// Générés par « jean link code » (ou affichés par « jean link »), ils sont
|
||||||
|
// partagés avec le worker « jean link --foreground » (systemd, autre process) via
|
||||||
|
// un fichier JeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas.
|
||||||
|
// Stockés HACHÉS (SHA-256) : le fichier ne révèle aucun code. Chaque code expire
|
||||||
|
// au bout de 10 min et est consommé (retiré) au premier appairage réussi.
|
||||||
|
|
||||||
|
const pairCodeTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
type pairEntry struct {
|
||||||
|
Hash string `json:"h"` // hex SHA-256 du code
|
||||||
|
Exp int64 `json:"e"` // expiration (unix ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairCodesPath() string { return filepath.Join(JeanHome(), ".pair_codes") }
|
||||||
|
|
||||||
|
func hashPairCode(code string) string {
|
||||||
|
h := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPairEntries lit le store et purge au passage les entrées expirées.
|
||||||
|
func loadPairEntries() []pairEntry {
|
||||||
|
b, err := os.ReadFile(pairCodesPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var es []pairEntry
|
||||||
|
if json.Unmarshal(b, &es) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
kept := es[:0]
|
||||||
|
for _, e := range es {
|
||||||
|
if e.Exp > now {
|
||||||
|
kept = append(kept, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
func savePairEntries(es []pairEntry) error {
|
||||||
|
if err := os.MkdirAll(JeanHome(), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(es)
|
||||||
|
tmp := pairCodesPath() + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, pairCodesPath()) // remplacement atomique
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPairCode génère un code frais (usage unique, 10 min), le persiste haché et
|
||||||
|
// le retourne en clair.
|
||||||
|
func newPairCode() (string, error) {
|
||||||
|
b := make([]byte, 5) // 40 bits → 8 caractères base32
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||||
|
es := append(loadPairEntries(), pairEntry{
|
||||||
|
Hash: hashPairCode(code),
|
||||||
|
Exp: time.Now().Add(pairCodeTTL).UnixMilli(),
|
||||||
|
})
|
||||||
|
if err := savePairEntries(es); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumePairCode renvoie true si le code est valide (non expiré, non déjà
|
||||||
|
// utilisé) et le CONSOMME (usage unique).
|
||||||
|
func consumePairCode(code string) bool {
|
||||||
|
if strings.TrimSpace(code) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := hashPairCode(code)
|
||||||
|
es := loadPairEntries()
|
||||||
|
out := make([]pairEntry, 0, len(es))
|
||||||
|
found := false
|
||||||
|
for _, e := range es {
|
||||||
|
if !found && e.Hash == want {
|
||||||
|
found = true // retiré du store = consommé
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
_ = savePairEntries(out)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitation des tentatives d'appairage (anti-brute-force du code via le relais).
|
||||||
|
var (
|
||||||
|
pairFailMu sync.Mutex
|
||||||
|
pairFails int
|
||||||
|
pairLockAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
func pairLocked() bool {
|
||||||
|
pairFailMu.Lock()
|
||||||
|
defer pairFailMu.Unlock()
|
||||||
|
if pairFails >= 10 {
|
||||||
|
if time.Since(pairLockAt) < 5*time.Minute {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
pairFails = 0 // fenêtre expirée
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairRecordFail() {
|
||||||
|
pairFailMu.Lock()
|
||||||
|
defer pairFailMu.Unlock()
|
||||||
|
pairFails++
|
||||||
|
pairLockAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pairReset() {
|
||||||
|
pairFailMu.Lock()
|
||||||
|
defer pairFailMu.Unlock()
|
||||||
|
pairFails = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Anti-rejeu -------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
replayMu sync.Mutex
|
||||||
|
replaySeen = map[string]time.Time{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// replayCheck renvoie true si la requête (upub|ts|iv) a déjà été vue (= rejeu).
|
||||||
|
// Sinon elle l'enregistre. Purge opportuniste des entrées hors fenêtre.
|
||||||
|
func replayCheck(uPub string, ts int64, iv string) bool {
|
||||||
|
key := uPub + "|" + fmt.Sprint(ts) + "|" + iv
|
||||||
|
now := time.Now()
|
||||||
|
replayMu.Lock()
|
||||||
|
defer replayMu.Unlock()
|
||||||
|
if len(replaySeen) > 4096 {
|
||||||
|
for k, t := range replaySeen {
|
||||||
|
if now.Sub(t) > 2*e2eAuthWindowMs*time.Millisecond {
|
||||||
|
delete(replaySeen, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := replaySeen[key]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
replaySeen[key] = now
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Dérivation de clé + ouverture authentifiée ----------------------------
|
||||||
|
|
||||||
|
// e2eAuthKey dérive la clé du canal authentifié pour l'utilisateur uPub :
|
||||||
|
// SHA256( ECDH(agentPriv, uPub) || "ajean-authchan-v1" ). Symétrique de la version
|
||||||
|
// navigateur (WASM) qui calcule ECDH(uPriv, agentPub) = même secret partagé.
|
||||||
|
func e2eAuthKey(uPubHex string) ([]byte, error) {
|
||||||
|
priv, err := e2ePrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uPubBytes, err := hex.DecodeString(uPubHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uPub, err := ecdh.X25519().NewPublicKey(uPubBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ss, err := priv.ECDH(uPub)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write(ss)
|
||||||
|
h.Write([]byte("ajean-authchan-v1"))
|
||||||
|
return h.Sum(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func e2eAuthAAD(uPub string, ts int64) []byte {
|
||||||
|
return []byte(uPub + "|" + fmt.Sprint(ts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// e2eAuthOpenReq lit l'enveloppe authentifiée {upub, ts, iv, ct} d'une requête,
|
||||||
|
// vérifie l'appairage / l'horodatage / le rejeu, puis déchiffre. Renvoie le clair
|
||||||
|
// et la clé de canal (réutilisée pour chiffrer la réponse).
|
||||||
|
func e2eAuthOpenReq(r *http.Request) (plain []byte, key []byte, err error) {
|
||||||
|
var env struct {
|
||||||
|
UPub string `json:"upub"`
|
||||||
|
Ts int64 `json:"ts"`
|
||||||
|
Iv string `json:"iv"`
|
||||||
|
Ct string `json:"ct"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("enveloppe invalide")
|
||||||
|
}
|
||||||
|
if !isAuthorizedUser(env.UPub) {
|
||||||
|
return nil, nil, fmt.Errorf("appareil non appairé (confirme le code d'appairage)")
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if d := now - env.Ts; d > e2eAuthWindowMs || d < -e2eAuthWindowMs {
|
||||||
|
return nil, nil, fmt.Errorf("horodatage hors fenêtre")
|
||||||
|
}
|
||||||
|
if replayCheck(env.UPub, env.Ts, env.Iv) {
|
||||||
|
return nil, nil, fmt.Errorf("rejeu détecté")
|
||||||
|
}
|
||||||
|
key, err = e2eAuthKey(env.UPub)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
gcm, err := newGCM(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
iv, _ := base64.StdEncoding.DecodeString(env.Iv)
|
||||||
|
ct, _ := base64.StdEncoding.DecodeString(env.Ct)
|
||||||
|
plain, err = gcm.Open(nil, iv, ct, e2eAuthAAD(env.UPub, env.Ts))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("authentification échouée")
|
||||||
|
}
|
||||||
|
return plain, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleE2EPair : appairage d'une identité utilisateur. Le navigateur scelle
|
||||||
|
// {upub, code} vers la clé publique de l'agent (le relais ne peut ni l'ouvrir ni
|
||||||
|
// connaître le code, affiché uniquement dans le log du serveur). Si le code matche,
|
||||||
|
// uPub est enregistré comme autorisé.
|
||||||
|
func handleE2EPair(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if pairLocked() {
|
||||||
|
http.Error(w, "trop de tentatives d'appairage, réessaie plus tard", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Sealed string `json:"sealed"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "appairage: requête invalide", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blob, err := base64.StdEncoding.DecodeString(body.Sealed)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "appairage: format", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := e2eOpenSeal(blob)
|
||||||
|
if err != nil {
|
||||||
|
pairRecordFail()
|
||||||
|
http.Error(w, "appairage: sceau invalide", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pm struct {
|
||||||
|
UPub string `json:"upub"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(plain, &pm); err != nil {
|
||||||
|
http.Error(w, "appairage: contenu", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !consumePairCode(pm.Code) {
|
||||||
|
pairRecordFail()
|
||||||
|
http.Error(w, "appairage: code incorrect ou expiré", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := authorizeUser(pm.UPub); err != nil {
|
||||||
|
http.Error(w, "appairage: "+err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pairReset()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@ import (
|
|||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -78,48 +80,103 @@ func relayURL() string {
|
|||||||
return defaultRelayURL
|
return defaultRelayURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linkServiceName est l'unité systemd qui exécute le worker « jean link --foreground ».
|
||||||
|
const linkServiceName = "jean-link"
|
||||||
|
|
||||||
func cmdLink(args []string) error {
|
func cmdLink(args []string) error {
|
||||||
// Sous-commandes utilitaires.
|
sub := ""
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
switch args[0] {
|
sub = args[0]
|
||||||
case "status":
|
}
|
||||||
tok := readLinkToken()
|
switch sub {
|
||||||
if tok == "" {
|
case "serve", "--foreground", "fg":
|
||||||
fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link <token>")
|
// Le worker réel (boucle de connexion au relais), pendant de `jean serve`.
|
||||||
return nil
|
// C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main.
|
||||||
}
|
return runLinkForeground()
|
||||||
fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
|
case "status":
|
||||||
return nil
|
tok := readLinkToken()
|
||||||
case "logout":
|
if tok == "" {
|
||||||
_ = os.Remove(linkTokenPath())
|
fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link <token>")
|
||||||
fmt.Println(green("[ok]") + " token supprimé")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
|
||||||
|
if linkServiceActive() {
|
||||||
|
fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "logout":
|
||||||
|
_ = os.Remove(linkTokenPath())
|
||||||
|
fmt.Println(green("[ok]") + " token supprimé")
|
||||||
|
return nil
|
||||||
|
case "stop":
|
||||||
|
return linkServiceCtl("stop")
|
||||||
|
case "restart":
|
||||||
|
if err := linkServiceCtl("restart"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return linkPrintIdentity()
|
||||||
|
case "code":
|
||||||
|
code, err := newPairCode()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("génération du code (droits sur %s ?): %w", JeanHome(), err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s code d'appairage (valable 10 min, à usage unique) :\n %s\n", green("[link]"), bold(code))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
token := readLinkToken()
|
// Sans sous-commande : (optionnellement) enregistrer le token, puis DÉMARRER le
|
||||||
if len(args) > 0 && args[0] != "" {
|
// service en arrière-plan (ne bloque pas le terminal) et afficher empreinte + code.
|
||||||
token = strings.TrimSpace(args[0])
|
gotToken := sub != ""
|
||||||
if err := saveLinkToken(token); err != nil {
|
if gotToken {
|
||||||
|
if err := saveLinkToken(strings.TrimSpace(sub)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token == "" {
|
if readLinkToken() == "" {
|
||||||
return fmt.Errorf("aucun token. Usage: jean link <token> (token fourni à l'achat sur la boutique)")
|
return fmt.Errorf("aucun token. Usage: jean link <token> (token fourni à l'achat sur la boutique)")
|
||||||
}
|
}
|
||||||
|
// Un nouveau token n'est pris en compte qu'au redémarrage du worker.
|
||||||
|
action := "start"
|
||||||
|
if gotToken {
|
||||||
|
action = "restart"
|
||||||
|
}
|
||||||
|
if err := linkServiceCtl(action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return linkPrintIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
|
// linkPrintIdentity affiche l'empreinte E2E (à confirmer une fois) et un code
|
||||||
fmt.Printf(" (UI Jean + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n")
|
// d'appairage frais (à saisir une fois) pour le portail.
|
||||||
|
func linkPrintIdentity() error {
|
||||||
// Empreinte E2E : à confirmer UNE FOIS dans le portail. Garantit que le chat
|
|
||||||
// est chiffré vers CET agent et pas vers un relais qui se ferait passer pour lui.
|
|
||||||
if fp := e2eFingerprint(); fp != "" {
|
if fp := e2eFingerprint(); fp != "" {
|
||||||
fmt.Printf("\n%s empreinte E2E de cette machine :\n %s\n", green("[e2e]"), bold(fp))
|
fmt.Printf("\n%s empreinte E2E de cette machine :\n %s\n", green("[e2e]"), bold(fp))
|
||||||
fmt.Printf(" Confirme-la dans le portail (Mon compte → serveur) pour activer la boîte noire.\n")
|
fmt.Printf(" Confirme-la dans le portail (Mon compte → serveur) pour activer la boîte noire.\n")
|
||||||
}
|
}
|
||||||
// Code d'appairage : à saisir UNE FOIS dans le portail. Authentifie TON navigateur
|
code, err := newPairCode()
|
||||||
// auprès de l'agent → même un relais compromis ne peut pas émettre de commandes.
|
if err != nil {
|
||||||
fmt.Printf(" Code d'appairage (à saisir une fois dans le portail) : %s\n\n", bold(currentPairCode()))
|
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), JeanHome(), err)
|
||||||
|
fmt.Printf(" Réessaie : sudo jean link code\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" Code d'appairage (valable 10 min, usage unique) : %s\n\n", bold(code))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLinkForeground exécute la boucle de connexion au relais (le worker supervisé
|
||||||
|
// par systemd). Reconnexion automatique avec backoff. Ne rend jamais la main.
|
||||||
|
func runLinkForeground() error {
|
||||||
|
token := readLinkToken()
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("aucun token. Usage: jean link <token>")
|
||||||
|
}
|
||||||
|
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
|
||||||
|
fmt.Printf(" (UI Jean + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n")
|
||||||
|
if fp := e2eFingerprint(); fp != "" {
|
||||||
|
fmt.Printf("%s empreinte E2E : %s (code d'appairage : jean link code)\n", green("[e2e]"), bold(fp))
|
||||||
|
}
|
||||||
|
|
||||||
// On construit le handler une seule fois ; il est servi à travers chaque tunnel.
|
// On construit le handler une seule fois ; il est servi à travers chaque tunnel.
|
||||||
handler := newLinkHandler()
|
handler := newLinkHandler()
|
||||||
@@ -140,6 +197,39 @@ func cmdLink(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linkServiceCtl pilote l'unité systemd jean-link (start/stop/restart), avec sudo
|
||||||
|
// non interactif si on n'est pas root. Linux uniquement (le relais cible est Linux).
|
||||||
|
func linkServiceCtl(action string) error {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return fmt.Errorf("gestion du service %s : Linux/systemd uniquement (lance « jean link --foreground » directement)", linkServiceName)
|
||||||
|
}
|
||||||
|
bin, pre := "systemctl", []string{}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
bin, pre = "sudo", []string{"-n", "systemctl"}
|
||||||
|
}
|
||||||
|
cmd := exec.Command(bin, append(pre, action, linkServiceName)...)
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName, err)
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "start", "restart":
|
||||||
|
fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName, action+"é")
|
||||||
|
case "stop":
|
||||||
|
fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkServiceActive indique si l'unité systemd jean-link tourne.
|
||||||
|
func linkServiceActive() bool {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output()
|
||||||
|
return strings.TrimSpace(string(out)) == "active"
|
||||||
|
}
|
||||||
|
|
||||||
// newLinkHandler construit le handler servi à travers le tunnel :
|
// newLinkHandler construit le handler servi à travers le tunnel :
|
||||||
// - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint
|
// - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint
|
||||||
// compatible OpenAI, avec injection de la clé API locale) → permet de
|
// compatible OpenAI, avec injection de la clé API locale) → permet de
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.2.5"
|
const Version = "0.2.6"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
args := os.Args[1:]
|
args := os.Args[1:]
|
||||||
@@ -92,8 +92,11 @@ Interaction:
|
|||||||
web [PORT] UI web (défaut :8090) — chat + presets + skills + tools
|
web [PORT] UI web (défaut :8090) — chat + presets + skills + tools
|
||||||
|
|
||||||
Accès distant (ajean.link) :
|
Accès distant (ajean.link) :
|
||||||
link <token> connecte ce Jean au relais public (accès web depuis partout, sans ouvrir de port)
|
link [token] démarre le lien au relais en arrière-plan (service) ; token = 1re fois / pour le changer
|
||||||
|
link restart | stop redémarre / arrête le service de lien
|
||||||
|
link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail
|
||||||
link status | logout état du lien / oublier le token
|
link status | logout état du lien / oublier le token
|
||||||
|
link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'jean serve')
|
||||||
|
|
||||||
LLM-side outils:
|
LLM-side outils:
|
||||||
skills [on|off|list] active la lecture de SKILLS/<nom>/SKILL.md par l'IA
|
skills [on|off|list] active la lecture de SKILLS/<nom>/SKILL.md par l'IA
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -582,6 +583,39 @@ type chatReq struct {
|
|||||||
Skills *bool `json:"skills"`
|
Skills *bool `json:"skills"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sseHeartbeat garde la réponse SSE active en écrivant un commentaire (`: ping`,
|
||||||
|
// ignoré par le parseur côté navigateur, aucun contenu donc rien à chiffrer)
|
||||||
|
// toutes les ~15 s. Sans ça, un long silence (exécution d'outil en mode agent,
|
||||||
|
// gros prefill) laisse la réponse inactive et un proxy intermédiaire (Cloudflare,
|
||||||
|
// ~100 s) la coupe → le fetch navigateur échoue (« Load failed »). Retourne un
|
||||||
|
// mutex à partager avec l'émetteur (writes concurrents sur le même w) et une
|
||||||
|
// fonction d'arrêt à différer.
|
||||||
|
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
||||||
|
mu := &sync.Mutex{}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
mu.Lock()
|
||||||
|
_, err := w.Write([]byte(": ping\n\n"))
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return mu, func() { close(done) }
|
||||||
|
}
|
||||||
|
|
||||||
// runChatStream exécute le chat et pousse chaque événement (delta) via emit, qui
|
// runChatStream exécute le chat et pousse chaque événement (delta) via emit, qui
|
||||||
// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat.
|
// renvoie false pour interrompre. Partagé par handleChat et handleE2EChat.
|
||||||
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
|
||||||
@@ -626,8 +660,12 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
flusher, _ := w.(http.Flusher)
|
flusher, _ := w.(http.Flusher)
|
||||||
|
mu, stop := sseHeartbeat(w, flusher)
|
||||||
|
defer stop()
|
||||||
emit := func(obj map[string]any) bool {
|
emit := func(obj map[string]any) bool {
|
||||||
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user