281c7c9a19
On Windows tailscaled stops the backend when the CLI client disconnects. --unattended keeps the VPN session alive so the agent can report its IP and the server can reach the node. Bump to v0.3.3.
204 lines
4.7 KiB
Go
204 lines
4.7 KiB
Go
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/<os>/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
|
|
}
|
|
|
|
|