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:
EduBox Dev
2026-06-23 09:48:00 +00:00
parent dd49993157
commit 124543d658
40 changed files with 1303 additions and 485 deletions
+64 -50
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"log"
"net"
"sync"
"time"
@@ -29,11 +28,6 @@ var (
mainConnMu sync.Mutex
)
var (
tsProxies = make(map[int]net.Listener)
tsProxiesMu sync.Mutex
)
func sendMessage(msg WSMessage) error {
mainConnMu.Lock()
defer mainConnMu.Unlock()
@@ -86,7 +80,7 @@ func notifyUI(msg map[string]interface{}) {
}
}
func startWebSocket(serverAddr, nodeID, dataDir string) {
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
if err != nil {
@@ -144,7 +138,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
break
}
log.Printf("WS received from server: action=%s", msg.Action)
handleMessage(conn, msg, dataDir, nodeID)
handleMessage(conn, msg, dataDir, nodeID, headscaleURL, headscaleAuthKey)
}
close(done)
@@ -157,7 +151,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
}
}
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headscaleURL, headscaleAuthKey string) {
switch msg.Action {
case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
@@ -176,6 +170,34 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
case "registered":
// Server acknowledged our register message; nothing to do.
return
case "start_vpn":
log.Printf("Server requested VPN start")
if headscaleURL == "" || headscaleAuthKey == "" {
log.Printf("Cannot start VPN: headscale config missing")
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
return
}
go func() {
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey)
if err != nil {
log.Printf("start_vpn error: %v", err)
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
return
}
for {
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
log.Printf("Waiting for WebSocket to send tailscale_ip...")
time.Sleep(1 * time.Second)
continue
}
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
}()
case "stop_vpn":
log.Printf("Server requested VPN stop")
stopTailscale()
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
case "activation_failed":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{
@@ -192,7 +214,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}); err != nil {
log.Printf("upsertInstance error: %v", err)
}
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
@@ -205,7 +227,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the EduBox mu-plugin can compute the public URL from the Host header.
// so the studioE5 mu-plugin can compute the public URL from the Host header.
go func() {
// Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second)
@@ -213,16 +235,8 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
}()
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
tsProxiesMu.Lock()
if _, exists := tsProxies[msg.Port]; !exists {
if ln, err := startTailscaleProxy(msg.Port); err == nil {
tsProxies[msg.Port] = ln
} else {
log.Printf("startTailscaleProxy error: %v", err)
}
}
tsProxiesMu.Unlock()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
@@ -230,15 +244,6 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "stop":
log.Printf("Stop instance %s", msg.InstanceID)
// Stop Tailscale proxy for this instance port
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
tsProxiesMu.Lock()
if ln, exists := tsProxies[inst[msg.InstanceID].Port]; exists {
_ = ln.Close()
delete(tsProxies, inst[msg.InstanceID].Port)
}
tsProxiesMu.Unlock()
}
if err := dockerComposeDown(dataDir, msg.InstanceID); err != nil {
log.Printf("dockerComposeDown error: %v", err)
}
@@ -249,21 +254,13 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "delete":
log.Printf("Delete instance %s", msg.InstanceID)
tsProxiesMu.Lock()
if inst, _ := loadInstances(dataDir); inst[msg.InstanceID] != nil {
if ln, exists := tsProxies[inst[msg.InstanceID].Port]; exists {
_ = ln.Close()
delete(tsProxies, inst[msg.InstanceID].Port)
}
}
tsProxiesMu.Unlock()
dockerComposeRm(dataDir, msg.InstanceID)
removeInstance(dataDir, msg.InstanceID)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "reset":
log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
@@ -276,7 +273,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
return
}
// Repair older WordPress instances: remove hardcoded WP_HOME/WP_SITEURL
// so the EduBox mu-plugin can compute the public URL from the Host header.
// so the studioE5 mu-plugin can compute the public URL from the Host header.
go func() {
// Give the container a moment to be ready before touching wp-config.php
time.Sleep(2 * time.Second)
@@ -284,16 +281,8 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
}()
// Start Tailscale proxy so the server can reach localhost via Tailscale IP
tsProxiesMu.Lock()
if _, exists := tsProxies[msg.Port]; !exists {
if ln, err := startTailscaleProxy(msg.Port); err == nil {
tsProxies[msg.Port] = ln
} else {
log.Printf("startTailscaleProxy error: %v", err)
}
}
tsProxiesMu.Unlock()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
@@ -303,3 +292,28 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("Unknown action: %s", msg.Action)
}
}
func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, port int) {
if headscaleURL == "" || headscaleAuthKey == "" {
log.Printf("Cannot ensure Tailscale: headscale config missing")
return
}
if isTailscaleRunning() {
return
}
log.Printf("Tailscale not running, starting it for instance port %d", port)
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey)
if err != nil {
log.Printf("ensureTailscale start error: %v", err)
return
}
for {
if err := sendMessage(WSMessage{Action: "tailscale_ip", NodeID: nodeID, TailscaleIP: ip}); err != nil {
log.Printf("Waiting for WebSocket to send tailscale_ip...")
time.Sleep(1 * time.Second)
continue
}
log.Printf("Sent tailscale_ip to server: %s", ip)
break
}
}