acces distant : le tunnel jean-link tourne aussi hors systemd (macOS/Windows)

linkServiceCtl/linkServiceActive etaient Linux-only : sur un Mac le token
etait bien enregistre mais rien ne composait jamais le tunnel, d'ou le
message << service arrete >> permanent. Hors Linux on lance desormais
'jean link serve' en processus detache suivi par un fichier PID
(spawnDetached/pidAlive/killTree, nouvelles primitives par plateforme).

- le mode app relance le tunnel au demarrage si une cle est enregistree
- nouvelle route POST /api/link/start + bouton << demarrer le tunnel >>
  dans le panneau, visible quand le lien est configure mais arrete
This commit is contained in:
nathaninline committed 2026-07-25 12:02:31 +02:00
1 parent 9787923879
commit 075a05ebaa
9 files changed
+214 -7

No files matched your search

+10
View File
@@ -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)
+91 -5
View File
@@ -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
+37
View File
@@ -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)
}
}
+26
View File
@@ -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()
}
+17 -1
View File
@@ -516,6 +516,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
<button onclick="window.open(document.getElementById('remote-url').value,'_blank')" title="ouvrir">↗</button>
</div>
<div id="remote-status" class="muted" style="font-size:11px;margin-top:6px"></div>
<button id="remote-start" onclick="remoteStart()" style="display:none;margin-top:8px;border-color:var(--accent);color:var(--accent)">démarrer le tunnel</button>
<div class="subhead" style="margin-top:12px">Première connexion<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">À votre première ouverture du portail distant, confirmez l'<b>empreinte</b> et saisissez le <b>code d'appairage</b> : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.</span></span></div>
<button onclick="remotePairCode()" style="margin-top:4px">Afficher l'empreinte et le code d'appairage</button>
<div id="remote-pair" class="muted" style="font-size:12px;margin-top:8px;display:none;line-height:1.7"></div>
@@ -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;
+1
View File
@@ -141,6 +141,7 @@
<button onclick="window.open(document.getElementById('remote-url').value,'_blank')" title="ouvrir">↗</button>
</div>
<div id="remote-status" class="muted" style="font-size:11px;margin-top:6px"></div>
<button id="remote-start" onclick="remoteStart()" style="display:none;margin-top:8px;border-color:var(--accent);color:var(--accent)">démarrer le tunnel</button>
<div class="subhead" style="margin-top:12px">Première connexion<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">À votre première ouverture du portail distant, confirmez l'<b>empreinte</b> et saisissez le <b>code d'appairage</b> : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.</span></span></div>
<button onclick="remotePairCode()" style="margin-top:4px">Afficher l'empreinte et le code d'appairage</button>
<div id="remote-pair" class="muted" style="font-size:12px;margin-top:8px;display:none;line-height:1.7"></div>
+16 -1
View File
@@ -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;
+15
View File
@@ -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) {
+1
View File
@@ -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)