package main import ( "context" "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "runtime" "sync" "syscall" "time" ) var ( tsCmd *exec.Cmd tsCmdMu sync.Mutex tsIP string tsDataDir string tsSocket string ) type tailscaleStatus struct { Self struct { TailscaleIPs []string `json:"TailscaleIPs"` } `json:"Self"` } func tailscaleBin(name string) string { // Prefer bundled binaries (tailscale-bin//tailscaled etc.). ex, err := os.Executable() if err == nil { bundled := filepath.Join(filepath.Dir(ex), "tailscale-bin", runtime.GOOS, name) if runtime.GOOS == "windows" { bundled += ".exe" } if _, err := os.Stat(bundled); err == nil { return bundled } } if p, err := exec.LookPath(name); err == nil { return p } return name } func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, error) { tsCmdMu.Lock() defer tsCmdMu.Unlock() if tsCmd != nil { return tsIP, nil } if dataDir == "" { return "", fmt.Errorf("tailscale data dir is empty") } tsDataDir = filepath.Join(dataDir, "tailscale") 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 (socket=%s)", nodeID, tsSocket) tsCmd = exec.Command(tailscaleBin("tailscaled"), "--state="+stateFile, "--socket="+tsSocket, "--tun=userspace-networking", ) 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) } // Give tailscaled a moment to start listening. time.Sleep(1 * time.Second) // Bring the interface up with the auth key. ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() upArgs := []string{ "--socket=" + tsSocket, "up", "--authkey=" + authKey, "--login-server=" + headscaleURL, "--hostname=" + nodeID, "--accept-dns=false", } if runtime.GOOS == "windows" { // On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects. upArgs = append(upArgs, "--unattended") } else { // --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() tsCmd = nil return "", fmt.Errorf("tailscale up: %w", err) } // Wait for an IP address. for { statusCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), "--socket="+tsSocket, "status", "--json", ) hideWindow(statusCmd) out, err := statusCmd.Output() if err != nil { select { case <-ctx.Done(): _ = tsCmd.Process.Kill() _ = tsCmd.Wait() tsCmd = nil return "", fmt.Errorf("tailscale status: %w", err) default: time.Sleep(1 * time.Second) continue } } var st tailscaleStatus if err := json.Unmarshal(out, &st); err == nil && len(st.Self.TailscaleIPs) > 0 { tsIP = st.Self.TailscaleIPs[0] break } select { case <-ctx.Done(): _ = tsCmd.Process.Kill() _ = tsCmd.Wait() tsCmd = nil return "", fmt.Errorf("tailscale IP timeout") default: time.Sleep(1 * time.Second) } } log.Printf("Tailscale started with IP: %s", tsIP) return tsIP, nil } func stopTailscale() { tsCmdMu.Lock() defer tsCmdMu.Unlock() stopTailscaleLocked() } func stopTailscaleLocked() { if tsCmd == nil || tsCmd.Process == nil { return } if tsSocket != "" { downCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down") hideWindow(downCmd) _ = downCmd.Run() } _ = tsCmd.Process.Kill() _ = tsCmd.Wait() tsCmd = nil tsIP = "" log.Printf("Tailscale stopped") } func isTailscaleRunning() bool { tsCmdMu.Lock() defer tsCmdMu.Unlock() if tsCmd == nil || tsCmd.Process == nil { return false } // Signal 0 checks process existence without affecting it. return tsCmd.Process.Signal(syscall.Signal(0)) == nil } func getTailscaleIP() string { tsCmdMu.Lock() defer tsCmdMu.Unlock() return tsIP }