Files
edubox/agent/tailscale.go
T
EduBox Dev 3a3e3ed202 fix(agent): correct version log, bump to v0.3.2, increase tailscale timeout
- Replace hardcoded AGENT_VERSION constant with injected version variable
- Bump agent version to 0.3.2
- Increase tailscale up/status timeout from 60s to 120s
2026-06-23 18:29:21 +00:00

201 lines
4.5 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" {
// --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
}