mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
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:
1 parent
9787923879
commit
075a05ebaa
9 files changed
+214
-7
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user