diff --git a/SUIVI_VPN_ONDEMAND.md b/SUIVI_VPN_ONDEMAND.md index a08b9bf..bfc439d 100644 --- a/SUIVI_VPN_ONDEMAND.md +++ b/SUIVI_VPN_ONDEMAND.md @@ -88,6 +88,29 @@ Instance de test créée : - Template : `wordpress-wordpress-latest` - État : WordPress répond sur `http://127.0.0.1:8001` **et en HTTPS public sur `https://test-wp-001.studioe5.edudeploy.com/`**. +## 🪟 Fix agent Windows v0.3.1 + +Problème rencontré sur le PC de test (`OMEGA-GAMER-dc166b1a`) : +- Le nœud apparaissait `online` dans le dashboard mais sans IP Tailscale. +- `tailscale.exe ip -4` retournait une erreur de connexion au socket local. + +Cause racine : +- L’agent lançait `tailscaled` avec `--socket=.sock`, mais **Tailscale sur Windows utilise des named pipes** (`\\.\pipe\...`), pas des sockets Unix. +- De plus, les commandes `podman`/`docker`/`tailscale` ouvraient une fenêtre console à chaque exécution. + +Corrections apportées (`agent/tailscale.go`, `agent/docker.go`, `agent/instance.go`, `agent/systray.go`, `agent/ui.go`, `agent/main.go`) : +- Sur Windows, utilisation de la named pipe `\\.\pipe\studioe5-tailscaled`. +- Application de `hideWindow` à tous les processus enfants (Tailscale, Podman, Docker, ouverture navigateur, redémarrage agent). +- Redirection des logs agent vers `/agent.log` et des logs `tailscaled` vers `/tailscale/tailscaled.log`. +- Suppression de `--operator=root` sur Windows (non pertinent). +- Correction du chemin `dataDir` passé à `startTailscale` (évitait un double dossier `tailscale/tailscale`). + +Validation manuelle sur Windows : +```powershell +.\tailscaled.exe --state="C:\...\data\tailscale.state" --socket="\\.\pipe\studioe5-tailscaled" --tun=userspace-networking +.\tailscale.exe --socket="\\.\pipe\studioe5-tailscaled" status # => Logged out (NeedsLogin) +``` + ## 🛠️ Commandes utiles pour reprendre ### Voir l’agent de test @@ -166,11 +189,11 @@ L’agent est servi par Caddy depuis le dossier `agent/` monté dans le conteneu ### Binaires disponibles -- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.0-windows.zip` +- **Windows (archive complète)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.1-windows.zip` - Contient `studioE5-agent.exe` + `tailscale-bin/windows/` (`tailscale.exe`, `tailscaled.exe`, `wintun.dll`) + `README-Windows.txt`. -- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.0.exe` +- **Windows (exécutable seul)** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.1.exe` - Nécessite d’avoir installé Tailscale Windows séparément ou d’avoir les binaires dans `tailscale-bin/windows/`. -- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.0` +- **Linux** : `https://studioe5.edudeploy.com/studioE5-agent-v0.3.1` ### Builder / préparer les binaires diff --git a/agent/build.sh b/agent/build.sh index c07a869..1f615da 100755 --- a/agent/build.sh +++ b/agent/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -VERSION="0.3.0" +VERSION="0.3.1" APP_NAME="studioE5" BIN_NAME="studioE5-agent" LDFLAGS="-X main.version=${VERSION}" diff --git a/agent/docker.go b/agent/docker.go index de5e9ff..740ea9e 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -39,27 +39,33 @@ func writeCompose(dataDir, instanceID, compose string, port int) error { return os.WriteFile(f, []byte(compose), 0644) } +func configureEngineCmd(cmd *exec.Cmd, dir string) { + hideWindow(cmd) + logPath := filepath.Join(dir, "compose.log") + if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + cmd.Stdout = f + cmd.Stderr = f + } +} + func dockerComposeUp(dataDir, instanceID string) error { dir := instanceDir(dataDir, instanceID) cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + configureEngineCmd(cmd, dir) return cmd.Run() } func dockerComposeDown(dataDir, instanceID string) error { dir := instanceDir(dataDir, instanceID) cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + configureEngineCmd(cmd, dir) return cmd.Run() } func dockerComposeRm(dataDir, instanceID string) error { dir := instanceDir(dataDir, instanceID) cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + configureEngineCmd(cmd, dir) if err := cmd.Run(); err != nil { return err } @@ -104,15 +110,13 @@ fi defer os.Remove(scriptPath) cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/update-wp-urls.sh") - cpCmd.Stdout = os.Stdout - cpCmd.Stderr = os.Stderr + configureEngineCmd(cpCmd, dir) if err := cpCmd.Run(); err != nil { return err } execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/update-wp-urls.sh") - execCmd.Stdout = os.Stdout - execCmd.Stderr = os.Stderr + configureEngineCmd(execCmd, dir) return execCmd.Run() } @@ -141,14 +145,12 @@ fi defer os.Remove(scriptPath) cpCmd := exec.Command(engine, "compose", "-f", composeFile, "cp", scriptPath, "app:/tmp/strip-wp-urls.sh") - cpCmd.Stdout = os.Stdout - cpCmd.Stderr = os.Stderr + configureEngineCmd(cpCmd, dir) if err := cpCmd.Run(); err != nil { return err } execCmd := exec.Command(engine, "compose", "-f", composeFile, "exec", "-T", "app", "sh", "/tmp/strip-wp-urls.sh") - execCmd.Stdout = os.Stdout - execCmd.Stderr = os.Stderr + configureEngineCmd(execCmd, dir) return execCmd.Run() } diff --git a/agent/exec_unix.go b/agent/exec_unix.go new file mode 100644 index 0000000..599ffae --- /dev/null +++ b/agent/exec_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import "os/exec" + +// hideWindow is a no-op on non-Windows platforms. +func hideWindow(cmd *exec.Cmd) {} diff --git a/agent/exec_windows.go b/agent/exec_windows.go new file mode 100644 index 0000000..a2e6095 --- /dev/null +++ b/agent/exec_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +// hideWindow configures a command so that it does not open a console window +// when it starts. This is essential for the agent running on student Windows +// machines, otherwise every docker/podman/tailscale command flashes a window. +func hideWindow(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} diff --git a/agent/instance.go b/agent/instance.go index 4136d79..6779223 100644 --- a/agent/instance.go +++ b/agent/instance.go @@ -88,6 +88,7 @@ func getInstanceStatus(dataDir, instanceID string) string { // Try modern JSON format first cmd := exec.Command(engine, "compose", "-f", composeFile, "ps", "--format", "json") + hideWindow(cmd) out, err := cmd.Output() if err == nil { outStr := strings.TrimSpace(string(out)) @@ -119,6 +120,7 @@ func getInstanceStatus(dataDir, instanceID string) string { // Fallback: use "ps -q" which is supported by all docker-compose versions cmd = exec.Command(engine, "compose", "-f", composeFile, "ps", "-q") + hideWindow(cmd) out, err = cmd.Output() if err != nil { return "error" diff --git a/agent/main.go b/agent/main.go index 980be6d..c9b739f 100644 --- a/agent/main.go +++ b/agent/main.go @@ -42,6 +42,14 @@ func main() { log.Fatalf("Cannot create data-dir: %v", err) } + // Redirect agent logs to a file so the console can be hidden on Windows. + agentLogPath := filepath.Join(*dataDir, "agent.log") + if agentLogFile, err := os.OpenFile(agentLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + log.SetOutput(agentLogFile) + } else { + log.Printf("Cannot open agent log file %s: %v", agentLogPath, err) + } + cfg, _, err := loadOrCreateConfig(*dataDir) if err != nil { log.Fatalf("Cannot load config: %v", err) @@ -78,8 +86,7 @@ func main() { } func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) { - tsDir := filepath.Join(dataDir, "tailscale") - ip, err := startTailscale(tsDir, nodeID, headscaleURL, authKey) + ip, err := startTailscale(dataDir, nodeID, headscaleURL, authKey) if err != nil { log.Printf("Tailscale error: %v", err) return diff --git a/agent/systray.go b/agent/systray.go index fd64a12..a3c7a0d 100644 --- a/agent/systray.go +++ b/agent/systray.go @@ -80,7 +80,9 @@ func openBrowser(url string) { args = []string{url} } - if err := exec.Command(cmd, args...).Start(); err != nil { + openCmd := exec.Command(cmd, args...) + hideWindow(openCmd) + if err := openCmd.Start(); err != nil { log.Printf("Failed to open browser: %v", err) } } diff --git a/agent/tailscale.go b/agent/tailscale.go index 2719d1c..949bb3c 100644 --- a/agent/tailscale.go +++ b/agent/tailscale.go @@ -61,17 +61,29 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro if err := os.MkdirAll(tsDataDir, 0700); err != nil { return "", fmt.Errorf("create tailscale dir: %w", err) } - tsSocket = filepath.Join(tsDataDir, "tailscaled.sock") + if runtime.GOOS == "windows" { + // Windows uses named pipes for tailscaled IPC, not Unix sockets. + tsSocket = `\\.\pipe\studioe5-tailscaled` + } else { + tsSocket = filepath.Join(tsDataDir, "tailscaled.sock") + } stateFile := filepath.Join(tsDataDir, "tailscaled.state") - log.Printf("Starting tailscaled for node %s", nodeID) + log.Printf("Starting tailscaled for node %s (socket=%s)", nodeID, tsSocket) tsCmd = exec.Command(tailscaleBin("tailscaled"), "--state="+stateFile, "--socket="+tsSocket, "--tun=userspace-networking", ) - tsCmd.Stdout = os.Stdout - tsCmd.Stderr = os.Stderr + hideWindow(tsCmd) + // Redirect tailscaled output to a dedicated log file. + tsLogPath := filepath.Join(tsDataDir, "tailscaled.log") + if tsLogFile, err := os.OpenFile(tsLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil { + tsCmd.Stdout = tsLogFile + tsCmd.Stderr = tsLogFile + } else { + log.Printf("Cannot open tailscaled log file %s: %v", tsLogPath, err) + } if err := tsCmd.Start(); err != nil { tsCmd = nil return "", fmt.Errorf("start tailscaled: %w", err) @@ -84,17 +96,22 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), - "--socket="+tsSocket, + upArgs := []string{ + "--socket=" + tsSocket, "up", - "--authkey="+authKey, - "--login-server="+headscaleURL, - "--hostname="+nodeID, + "--authkey=" + authKey, + "--login-server=" + headscaleURL, + "--hostname=" + nodeID, "--accept-dns=false", - "--operator=root", - ) - upCmd.Stdout = os.Stdout - upCmd.Stderr = os.Stderr + } + if runtime.GOOS != "windows" { + // --operator is only meaningful on Unix systems. + upArgs = append(upArgs, "--operator=root") + } + upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), upArgs...) + hideWindow(upCmd) + upCmd.Stdout = log.Writer() + upCmd.Stderr = log.Writer() if err := upCmd.Run(); err != nil { _ = tsCmd.Process.Kill() _ = tsCmd.Wait() @@ -104,10 +121,12 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro // Wait for an IP address. for { - out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"), + statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), "--socket="+tsSocket, "status", "--json", - ).Output() + ) + hideWindow(statusCmd) + out, err := statusCmd.Output() if err != nil { select { case <-ctx.Done(): @@ -151,7 +170,9 @@ func stopTailscaleLocked() { return } if tsSocket != "" { - _ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run() + downCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down") + hideWindow(downCmd) + _ = downCmd.Run() } _ = tsCmd.Process.Kill() _ = tsCmd.Wait() diff --git a/agent/ui.go b/agent/ui.go index 321c0cc..469a41c 100644 --- a/agent/ui.go +++ b/agent/ui.go @@ -61,6 +61,7 @@ func startUI(dataDir, nodeID, serverAddr string) { w.WriteHeader(http.StatusNoContent) go func() { cmd := exec.Command(os.Args[0], os.Args[1:]...) + hideWindow(cmd) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin