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
This commit is contained in:
+139
-65
@@ -2,104 +2,178 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
var globalTSServer *tsnet.Server
|
||||
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) {
|
||||
// Configure tsnet to use our Headscale server
|
||||
os.Setenv("TS_AUTHKEY", authKey)
|
||||
os.Setenv("TS_CONTROL_URL", headscaleURL)
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
|
||||
s := &tsnet.Server{
|
||||
Hostname: nodeID,
|
||||
Dir: dataDir,
|
||||
Logf: log.Printf,
|
||||
if tsCmd != nil {
|
||||
return tsIP, nil
|
||||
}
|
||||
|
||||
if err := s.Start(); err != nil {
|
||||
return "", fmt.Errorf("tailscale start: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
globalTSServer = s
|
||||
// Give tailscaled a moment to start listening.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Wait for Tailscale to come up and retrieve IP
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
// Bring the interface up with the auth key.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tailscale local client: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
var tailscaleIP string
|
||||
// Wait for an IP address.
|
||||
for {
|
||||
status, err := lc.Status(ctx)
|
||||
out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"),
|
||||
"--socket="+tsSocket,
|
||||
"status", "--json",
|
||||
).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tailscale status: %w", err)
|
||||
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
|
||||
}
|
||||
}
|
||||
if status.Self != nil && len(status.Self.TailscaleIPs) > 0 {
|
||||
tailscaleIP = status.Self.TailscaleIPs[0].String()
|
||||
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")
|
||||
case <-time.After(1 * time.Second):
|
||||
default:
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Tailscale started with IP: %s", tailscaleIP)
|
||||
return tailscaleIP, nil
|
||||
log.Printf("Tailscale started with IP: %s", tsIP)
|
||||
return tsIP, nil
|
||||
}
|
||||
|
||||
func startTailscaleProxy(port int) (net.Listener, error) {
|
||||
if globalTSServer == nil {
|
||||
return nil, fmt.Errorf("tailscale server not started")
|
||||
}
|
||||
ln, err := globalTSServer.Listen("tcp", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tailscale listen on port %d: %w", port, err)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("tailscale proxy accept error on port %d: %v", port, err)
|
||||
return
|
||||
}
|
||||
go handleProxyConn(conn, port)
|
||||
}
|
||||
}()
|
||||
log.Printf("Tailscale proxy started on port %d", port)
|
||||
return ln, nil
|
||||
func stopTailscale() {
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
stopTailscaleLocked()
|
||||
}
|
||||
|
||||
func handleProxyConn(src net.Conn, port int) {
|
||||
defer src.Close()
|
||||
dst, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", port))
|
||||
if err != nil {
|
||||
log.Printf("tailscale proxy dial localhost:%d error: %v", port, err)
|
||||
func stopTailscaleLocked() {
|
||||
if tsCmd == nil || tsCmd.Process == nil {
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
go func() {
|
||||
_, _ = io.Copy(dst, src)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
go func() {
|
||||
_, _ = io.Copy(src, dst)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
<-done
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user