fix(agent/windows): named pipe Tailscale + hideWindow + logs

- Use Windows named pipe \.\pipe\studioe5-tailscaled instead of Unix socket
- Apply hideWindow to all child processes (tailscale, podman, docker, browser)
- Redirect agent logs to <data-dir>/agent.log and tailscaled logs to tailscaled.log
- Fix double tailscale/ tailscale dir path in startTailscaleAndReport
- Remove --operator=root on Windows
- Bump agent version to 0.3.1
This commit is contained in:
EduBox Dev
2026-06-23 18:18:26 +00:00
parent 03b2f1267d
commit d090f67bff
10 changed files with 121 additions and 37 deletions
+26 -3
View File
@@ -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 :
- Lagent lançait `tailscaled` avec `--socket=<fichier>.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 `<data-dir>/agent.log` et des logs `tailscaled` vers `<data-dir>/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 lagent de test
@@ -166,11 +189,11 @@ Lagent 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 davoir installé Tailscale Windows séparément ou davoir 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
+1 -1
View File
@@ -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}"
+16 -14
View File
@@ -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()
}
+8
View File
@@ -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) {}
+18
View File
@@ -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
}
+2
View File
@@ -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"
+9 -2
View File
@@ -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
+3 -1
View File
@@ -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)
}
}
+36 -15
View File
@@ -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)
}
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()
+1
View File
@@ -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