Files
edubox/agent/tailscale.go
T
EduBox Dev 124543d658 feat(vpn): VPN on-demand Tailscale + agent studioE5 standalone
- Agent studioE5 standalone en Go (console + systray)
- VPN on-demand via tailscaled + tailscale up (authkey Headscale)
- Resolver/serveur dans le tailnet studioe5
- Caddy on-demand TLS pour les instances
- Nouveaux endpoints serveur /api/internal/send-to-node
- Suppression des anciens binaires edubox-agent
- Suivi dans SUIVI_VPN_ONDEMAND.md
2026-06-23 09:48:00 +00:00

180 lines
3.8 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)
}
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
}