diff --git a/README.md b/README.md index 5b0006d..f145bc6 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,9 @@ Interaction : web [PORT] interface web (défaut :8090) Accès distant (ajean.link) : - link 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 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. ```bash -jean web # l'UI doit tourner localement -jean link # token fourni sur ajean.link +jean link # token fourni sur ajean.link ; démarre le lien en arrière-plan +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 : - 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 ; @@ -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. - **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. - **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//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 -JEAN_LINK_ALLOW_OAI=1 jean link +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. diff --git a/e2e.go b/e2e.go index cb46be8..5e12fa0 100644 --- a/e2e.go +++ b/e2e.go @@ -160,6 +160,8 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") flusher, _ := w.(http.Flusher) + mu, stop := sseHeartbeat(w, flusher) + defer stop() emit := func(obj map[string]any) bool { b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}}) nonce := make([]byte, 12) @@ -167,6 +169,8 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) { return false } 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 { return false } diff --git a/e2eauth.go b/e2eauth.go new file mode 100644 index 0000000..2a29f90 --- /dev/null +++ b/e2eauth.go @@ -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}`)) +} diff --git a/link.go b/link.go index 1ba2747..e80e82b 100644 --- a/link.go +++ b/link.go @@ -23,7 +23,9 @@ import ( "net/http/httputil" "net/url" "os" + "os/exec" "path/filepath" + "runtime" "strings" "time" @@ -78,48 +80,103 @@ func relayURL() string { return defaultRelayURL } +// linkServiceName est l'unité systemd qui exécute le worker « jean link --foreground ». +const linkServiceName = "jean-link" + func cmdLink(args []string) error { - // Sous-commandes utilitaires. + sub := "" if len(args) > 0 { - switch args[0] { - case "status": - tok := readLinkToken() - if tok == "" { - fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link ") - return nil - } - fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) - return nil - case "logout": - _ = os.Remove(linkTokenPath()) - fmt.Println(green("[ok]") + " token supprimé") + sub = args[0] + } + switch sub { + case "serve", "--foreground", "fg": + // Le worker réel (boucle de connexion au relais), pendant de `jean serve`. + // C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main. + return runLinkForeground() + case "status": + tok := readLinkToken() + if tok == "" { + fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link ") 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() - if len(args) > 0 && args[0] != "" { - token = strings.TrimSpace(args[0]) - if err := saveLinkToken(token); err != nil { + // Sans sous-commande : (optionnellement) enregistrer le token, puis DÉMARRER le + // service en arrière-plan (ne bloque pas le terminal) et afficher empreinte + code. + gotToken := sub != "" + if gotToken { + if err := saveLinkToken(strings.TrimSpace(sub)); err != nil { return err } } - if token == "" { + if readLinkToken() == "" { return fmt.Errorf("aucun token. Usage: jean link (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()) - fmt.Printf(" (UI Jean + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n") - - // 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. +// linkPrintIdentity affiche l'empreinte E2E (à confirmer une fois) et un code +// d'appairage frais (à saisir une fois) pour le portail. +func linkPrintIdentity() error { if fp := e2eFingerprint(); 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") } - // Code d'appairage : à saisir UNE FOIS dans le portail. Authentifie TON navigateur - // auprès de l'agent → même un relais compromis ne peut pas émettre de commandes. - fmt.Printf(" Code d'appairage (à saisir une fois dans le portail) : %s\n\n", bold(currentPairCode())) + code, err := newPairCode() + if err != nil { + 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 ") + } + 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. 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 : // - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint // compatible OpenAI, avec injection de la clé API locale) → permet de diff --git a/main.go b/main.go index 3d3208f..fe24fbf 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "0.2.5" +const Version = "0.2.6" func main() { args := os.Args[1:] @@ -92,8 +92,11 @@ Interaction: web [PORT] UI web (défaut :8090) — chat + presets + skills + tools Accès distant (ajean.link) : - link 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 serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'jean serve') LLM-side outils: skills [on|off|list] active la lecture de SKILLS//SKILL.md par l'IA diff --git a/web.go b/web.go index 6ceae11..0b04de9 100644 --- a/web.go +++ b/web.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" ) @@ -582,6 +583,39 @@ type chatReq struct { 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 // renvoie false pour interrompre. Partagé par handleChat et handleE2EChat. 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("X-Accel-Buffering", "no") flusher, _ := w.(http.Flusher) + mu, stop := sseHeartbeat(w, flusher) + defer stop() emit := func(obj map[string]any) bool { 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 { return false }