feat(agent): v0.3.5 Windows inbound forwarding, UI actions, lifecycle
- Configure tailscale serve automatically for each instance on Windows userspace networking. - Add local UI buttons: start/stop/reset/delete instances (stop/start preserve volumes). - Clean shutdown: stop tailscaled and instances, notify server with instance_stopped. - Restart tailscaled on agent boot using persisted state when pre-auth key is absent. - Sync instance stopped/deleted status to dashboard (server/lib/websocket.ts). - Security: include prior authz/scoping changes across API routes, ephemeral pre-auth keys, ACL policy, internal API key. - Update SUIVI_VPN_ONDEMAND.md and docs/ONBOARDING_CLIENT.md. - Bump agent version to 0.3.5.
This commit is contained in:
+80
-1
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -61,6 +62,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
||||
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
|
||||
return "", fmt.Errorf("create tailscale dir: %w", err)
|
||||
}
|
||||
// Make sure a previous tailscaled (e.g. left behind after a crash or
|
||||
// force-kill) does not block the new daemon on the same socket/state.
|
||||
killStaleTailscaled(tsDataDir)
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows uses named pipes for tailscaled IPC, not Unix sockets.
|
||||
tsSocket = `\\.\pipe\studioe5-tailscaled`
|
||||
@@ -88,6 +92,9 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
||||
tsCmd = nil
|
||||
return "", fmt.Errorf("start tailscaled: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tsDataDir, "tailscaled.pid"), []byte(strconv.Itoa(tsCmd.Process.Pid)), 0644); err != nil {
|
||||
log.Printf("Cannot write tailscaled pid file: %v", err)
|
||||
}
|
||||
|
||||
// Give tailscaled a moment to start listening.
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -99,11 +106,14 @@ func startTailscale(dataDir, nodeID, headscaleURL, authKey string) (string, erro
|
||||
upArgs := []string{
|
||||
"--socket=" + tsSocket,
|
||||
"up",
|
||||
"--authkey=" + authKey,
|
||||
"--login-server=" + headscaleURL,
|
||||
"--hostname=" + nodeID,
|
||||
"--accept-dns=false",
|
||||
}
|
||||
// The auth key is omitted on reconnect: Tailscale reuses the existing state.
|
||||
if authKey != "" {
|
||||
upArgs = append(upArgs, "--authkey="+authKey)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
// On Windows, keep the VPN up even after the tailscale.exe CLI client disconnects.
|
||||
upArgs = append(upArgs, "--unattended")
|
||||
@@ -181,6 +191,9 @@ func stopTailscaleLocked() {
|
||||
_ = tsCmd.Wait()
|
||||
tsCmd = nil
|
||||
tsIP = ""
|
||||
if tsDataDir != "" {
|
||||
_ = os.Remove(filepath.Join(tsDataDir, "tailscaled.pid"))
|
||||
}
|
||||
log.Printf("Tailscale stopped")
|
||||
}
|
||||
|
||||
@@ -200,4 +213,70 @@ func getTailscaleIP() string {
|
||||
return tsIP
|
||||
}
|
||||
|
||||
// setupTailscaleServe configures Tailscale to proxy inbound Tailnet traffic
|
||||
// on the given TCP port to localhost:<port>. This is required on Windows
|
||||
// because userspace networking does not forward incoming connections to
|
||||
// loopback by default.
|
||||
func setupTailscaleServe(port int) error {
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
if tsSocket == "" {
|
||||
return fmt.Errorf("tailscale socket not initialized")
|
||||
}
|
||||
|
||||
portStr := strconv.Itoa(port)
|
||||
// Clean up any stale config for this port first.
|
||||
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
|
||||
hideWindow(offCmd)
|
||||
_ = offCmd.Run()
|
||||
|
||||
serveCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "tcp://localhost:"+portStr)
|
||||
hideWindow(serveCmd)
|
||||
out, err := serveCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("tailscale serve: %w: %s", err, string(out))
|
||||
}
|
||||
log.Printf("Tailscale serve configured for port %s", portStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeTailscaleServe removes the Tailscale serve proxy for a port when an
|
||||
// instance is stopped or deleted.
|
||||
func removeTailscaleServe(port int) {
|
||||
tsCmdMu.Lock()
|
||||
defer tsCmdMu.Unlock()
|
||||
if tsSocket == "" {
|
||||
return
|
||||
}
|
||||
portStr := strconv.Itoa(port)
|
||||
offCmd := exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "serve", "--bg", "--tcp="+portStr, "off")
|
||||
hideWindow(offCmd)
|
||||
_ = offCmd.Run()
|
||||
log.Printf("Tailscale serve removed for port %s", portStr)
|
||||
}
|
||||
|
||||
// killStaleTailscaled terminates a previously started tailscaled process that
|
||||
// may have been left running after the agent was force-killed.
|
||||
func killStaleTailscaled(tsDataDir string) {
|
||||
pidFile := filepath.Join(tsDataDir, "tailscaled.pid")
|
||||
data, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var pid int
|
||||
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
|
||||
return
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||
log.Printf("Killing stale tailscaled process %d", pid)
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
}
|
||||
_ = os.Remove(pidFile)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user