diff --git a/internal/jean/cli_app.go b/internal/jean/cli_app.go index e679799..cedd36b 100644 --- a/internal/jean/cli_app.go +++ b/internal/jean/cli_app.go @@ -42,6 +42,16 @@ func cmdApp(args []string) error { // Sert l'UI en tâche de fond ; l'icône tray tient le premier plan. go func() { _ = http.Serve(ln, newWebMux()) }() + // Accès distant déjà configuré ? On relance le tunnel. Sous Linux systemd s'en + // charge au boot, mais sur un poste de bureau (macOS/Windows) personne ne le + // fait : sans ça le token reste enregistré et le panneau affiche « service + // arrêté » à chaque démarrage de l'app. + go func() { + if readLinkToken() != "" && !linkServiceActive() { + _ = linkServiceCtl("start") + } + }() + sp := showSplash("Lancement de Jean en cours…") waitServerReady(url) _ = openBrowser(url) diff --git a/internal/jean/relay_link.go b/internal/jean/relay_link.go index cf37528..f3dbfbc 100644 --- a/internal/jean/relay_link.go +++ b/internal/jean/relay_link.go @@ -29,6 +29,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "time" @@ -274,11 +275,21 @@ func serveLocalWebMux(mux *http.ServeMux) { _ = http.Serve(ln, mux) } -// 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). +// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, +// Windows), où Jean est une app de bureau lancée sans droits root. +func linkPIDPath() string { return filepath.Join(JeanHome(), linkServiceName+".pid") } +func linkLogPath() string { return filepath.Join(JeanHome(), linkServiceName+".log") } + +// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est +// l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ; +// ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance +// « jean link serve » en processus détaché suivi par un fichier PID, comme le +// fait déjà le service principal. Sans ça, l'accès distant restait définitivement +// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne +// composait jamais le tunnel. 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) + return linkUserSvcCtl(action) } bin, pre := "systemctl", []string{} if os.Geteuid() != 0 { @@ -298,15 +309,90 @@ func linkServiceCtl(action string) error { return nil } -// linkServiceActive indique si l'unité systemd jean-link tourne. +// linkServiceActive indique si le worker de lien tourne (unité systemd sous +// Linux, processus suivi par fichier PID ailleurs). func linkServiceActive() bool { if runtime.GOOS != "linux" { - return false + return linkUserPID() > 0 } out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output() return strings.TrimSpace(string(out)) == "active" } +// linkUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon +// (fichier absent, illisible, ou process mort → on nettoie le fichier obsolète). +func linkUserPID() int { + b, err := os.ReadFile(linkPIDPath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + if pid <= 0 || !pidAlive(pid) { + return 0 + } + return pid +} + +// linkUserSvcCtl : équivalent de systemctl pour les OS sans systemd. +func linkUserSvcCtl(action string) error { + switch action { + case "stop", "restart": + if pid := linkUserPID(); pid > 0 { + killTree(pid) + for i := 0; i < 30 && pidAlive(pid); i++ { + time.Sleep(100 * time.Millisecond) + } + } + _ = os.Remove(linkPIDPath()) + if action == "stop" { + fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName) + return nil + } + case "start": + if linkUserPID() > 0 { + fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName) + return nil + } + default: + return fmt.Errorf("action inconnue: %s", action) + } + + self, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + return err + } + logf, err := os.OpenFile(linkLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("ouverture du log %s: %w", linkLogPath(), err) + } + defer logf.Close() + + cmd := spawnDetached(self, "link", "serve") + cmd.Stdout, cmd.Stderr = logf, logf + if err := cmd.Start(); err != nil { + return fmt.Errorf("démarrage de « jean link serve »: %w", err) + } + pid := cmd.Process.Pid + if err := os.WriteFile(linkPIDPath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { + return fmt.Errorf("écriture du PID: %w", err) + } + _ = cmd.Process.Release() + + // Le worker peut mourir aussitôt (token refusé, relais injoignable) : on laisse + // passer un instant avant de déclarer la victoire, sinon l'UI afficherait + // « en ligne » sur un process déjà mort. + time.Sleep(1500 * time.Millisecond) + if !pidAlive(pid) { + _ = os.Remove(linkPIDPath()) + return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) + } + fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName, pid) + return nil +} + // 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/internal/jean/sys_platform_unix.go b/internal/jean/sys_platform_unix.go index 922717c..7645c62 100644 --- a/internal/jean/sys_platform_unix.go +++ b/internal/jean/sys_platform_unix.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "syscall" + "time" ) // defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean @@ -295,3 +296,39 @@ func ramUsageMB() (used, total int) { } return (totalKB - availKB) / 1024, totalKB / 1024 } + +// --- Supervision de processus détachés (worker de lien, service en mode +// utilisateur). Pendant Unix de sys_platform_windows.go. + +// spawnDetached prépare une commande qui survivra à la mort de Jean : Setsid la +// place dans une nouvelle session, donc elle n'est pas tuée avec notre groupe. +func spawnDetached(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return cmd +} + +// pidAlive : le signal 0 ne tue rien, il teste juste l'existence du process. +func pidAlive(pid int) bool { + if pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +// killTree arrête le process ET ses enfants. Setsid ayant fait de lui un chef de +// groupe, un PID négatif vise le groupe entier ; SIGKILL en dernier recours. +func killTree(pid int) { + if pid <= 0 { + return + } + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + for i := 0; i < 30 && pidAlive(pid); i++ { + time.Sleep(100 * time.Millisecond) + } + if pidAlive(pid) { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } +} diff --git a/internal/jean/sys_platform_windows.go b/internal/jean/sys_platform_windows.go index 56d0912..2e6bbb3 100644 --- a/internal/jean/sys_platform_windows.go +++ b/internal/jean/sys_platform_windows.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" "unsafe" @@ -390,3 +391,28 @@ func ramUsageMB() (used, total int) { const mb = 1024 * 1024 return int((m.ullTotalPhys - m.ullAvailPhys) / mb), int(m.ullTotalPhys / mb) } + +// --- Supervision de processus détachés (worker de lien, service). Pendant +// Windows de sys_platform_unix.go. + +// spawnDetached prépare une commande détachée et SANS console : en mode app +// (double-clic) le moindre enfant console ferait clignoter une fenêtre noire. +func spawnDetached(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow, + } + return cmd +} + +// pidAlive : alias de processAlive (API commune avec Unix). +func pidAlive(pid int) bool { return processAlive(pid) } + +// killTree arrête le process et toute sa descendance (taskkill /T). +func killTree(pid int) { + if pid <= 0 { + return + } + _ = hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")).Run() +} diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 845fa9d..c0863a5 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -516,6 +516,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
+
Première connexion?À votre première ouverture du portail distant, confirmez l'empreinte et saisissez le code d'appairage : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.
@@ -2238,7 +2239,9 @@ function renderRemote(d){ document.getElementById('remote-url').value = d.machineURL || ''; const st = document.getElementById('remote-status'); if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; } - else { st.textContent='○ service arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + else { st.textContent='○ tunnel arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + const sb = document.getElementById('remote-start'); + if(sb){ sb.style.display = d.active ? 'none' : ''; } if(badge){ badge.textContent = d.active ? '● connecté' : '○ arrêté'; badge.style.color = d.active?'var(--accent)':'var(--warn)'; } } @@ -2277,6 +2280,19 @@ function remoteConnect(){ window.addEventListener('message', onMsg); } +// Relance le tunnel avec la clé déjà enregistrée (sans repasser par la popup). +async function remoteStart(){ + const b = document.getElementById('remote-start'); + if(b){ b.disabled = true; b.textContent = 'démarrage…'; } + try{ + const r = await jpost('/api/link/start', {}); + if(r && r.active){ toast('✓ tunnel démarré'); } + else { toast((r && r.error) || 'le tunnel n\'a pas démarré'); } + }catch(e){ toast('erreur : '+e); } + if(b){ b.disabled = false; b.textContent = 'démarrer le tunnel'; } + loadRemote(); +} + async function remoteDisconnect(){ const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'}); if(!ok) return; diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 2cf5a27..4b79619 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -141,6 +141,7 @@
+
Première connexion?À votre première ouverture du portail distant, confirmez l'empreinte et saisissez le code d'appairage : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.
diff --git a/internal/jean/ui/src/js/13-remote.js b/internal/jean/ui/src/js/13-remote.js index 57e8910..6a960f6 100644 --- a/internal/jean/ui/src/js/13-remote.js +++ b/internal/jean/ui/src/js/13-remote.js @@ -17,7 +17,9 @@ function renderRemote(d){ document.getElementById('remote-url').value = d.machineURL || ''; const st = document.getElementById('remote-status'); if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; } - else { st.textContent='○ service arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + else { st.textContent='○ tunnel arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + const sb = document.getElementById('remote-start'); + if(sb){ sb.style.display = d.active ? 'none' : ''; } if(badge){ badge.textContent = d.active ? '● connecté' : '○ arrêté'; badge.style.color = d.active?'var(--accent)':'var(--warn)'; } } @@ -56,6 +58,19 @@ function remoteConnect(){ window.addEventListener('message', onMsg); } +// Relance le tunnel avec la clé déjà enregistrée (sans repasser par la popup). +async function remoteStart(){ + const b = document.getElementById('remote-start'); + if(b){ b.disabled = true; b.textContent = 'démarrage…'; } + try{ + const r = await jpost('/api/link/start', {}); + if(r && r.active){ toast('✓ tunnel démarré'); } + else { toast((r && r.error) || 'le tunnel n\'a pas démarré'); } + }catch(e){ toast('erreur : '+e); } + if(b){ b.disabled = false; b.textContent = 'démarrer le tunnel'; } + loadRemote(); +} + async function remoteDisconnect(){ const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'}); if(!ok) return; diff --git a/internal/jean/web_link_api.go b/internal/jean/web_link_api.go index 4086940..9b137ea 100644 --- a/internal/jean/web_link_api.go +++ b/internal/jean/web_link_api.go @@ -63,6 +63,21 @@ func handleLinkConnect(w http.ResponseWriter, r *http.Request) { }) } +// handleLinkStart (POST /api/link/start) : (re)démarre le worker de lien sans +// repasser par la popup de connexion — le token est déjà enregistré. Utile quand +// le tunnel est tombé, ou sur un poste sans systemd où personne ne le relance. +func handleLinkStart(w http.ResponseWriter, r *http.Request) { + if readLinkToken() == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "aucune clé de liaison enregistrée"}) + return + } + if err := linkServiceCtl("restart"); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": linkServiceActive()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "active": linkServiceActive()}) +} + // handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie // la clé (équivalent `jean link stop` + `jean link logout`). func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index a11abf2..2b50d48 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -109,6 +109,7 @@ func newWebMux() *http.ServeMux { api("/api/oai/public", handleOAIPublic) api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link) api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → jean link + api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion api("/api/internet", handleInternet)