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) } tsSocket = filepath.Join(tsDataDir, "tailscaled.sock") stateFile := filepath.Join(tsDataDir, "tailscaled.state") log.Printf("Starting tailscaled for node %s", nodeID) tsCmd = exec.Command(tailscaleBin("tailscaled"), "--state="+stateFile, "--socket="+tsSocket, "--tun=userspace-networking", ) tsCmd.Stdout = os.Stdout tsCmd.Stderr = os.Stderr 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(), 60*time.Second) defer cancel() upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"), "--socket="+tsSocket, "up", "--authkey="+authKey, "--login-server="+headscaleURL, "--hostname="+nodeID, "--accept-dns=false", "--operator=root", ) upCmd.Stdout = os.Stdout upCmd.Stderr = os.Stderr 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 { out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"), "--socket="+tsSocket, "status", "--json", ).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 != "" { _ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").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 }