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:
+308
-8
@@ -8,6 +8,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
@@ -17,6 +21,25 @@ var uiHTML string
|
||||
|
||||
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
|
||||
// uiConnections holds active WebSocket connections from local UI clients.
|
||||
var (
|
||||
uiConnections = make(map[*websocket.Conn]bool)
|
||||
uiConnectionsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// uiLogWriter intercepts log output and forwards it to connected UI clients.
|
||||
type uiLogWriter struct{}
|
||||
|
||||
func (w uiLogWriter) Write(p []byte) (n int, err error) {
|
||||
line := strings.TrimSpace(string(p))
|
||||
if line != "" {
|
||||
sendUILog(line)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
func startUI(dataDir, nodeID, serverAddr string) {
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
@@ -32,8 +55,16 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
|
||||
json.NewEncoder(w).Encode(cfg)
|
||||
// Expose a merged view with the agent version for the UI.
|
||||
response := map[string]interface{}{
|
||||
"server": cfg.Server,
|
||||
"headscale_url": cfg.HeadscaleURL,
|
||||
"headscale_auth_key": cfg.HeadscaleAuthKey,
|
||||
"node_id": cfg.NodeID,
|
||||
"data_dir": cfg.DataDir,
|
||||
"version": version,
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
case http.MethodPost:
|
||||
var cfg AgentConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
@@ -80,23 +111,33 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
uiConnectionsMu.Lock()
|
||||
uiConnections[conn] = true
|
||||
uiConnectionsMu.Unlock()
|
||||
log.Printf("UI client connected from %s", r.RemoteAddr)
|
||||
|
||||
// Send current status immediately.
|
||||
sendUIStatus(conn, dataDir)
|
||||
|
||||
// Register notifier to forward activation results from main WS to this UI connection
|
||||
notifierID := registerUINotifier(func(msg map[string]interface{}) {
|
||||
log.Printf("UI notifier forwarding to browser: %+v", msg)
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
log.Printf("UI notify error: %v", err)
|
||||
} else {
|
||||
log.Printf("UI notifier sent successfully")
|
||||
}
|
||||
})
|
||||
defer unregisterUINotifier(notifierID)
|
||||
defer func() {
|
||||
unregisterUINotifier(notifierID)
|
||||
uiConnectionsMu.Lock()
|
||||
delete(uiConnections, conn)
|
||||
uiConnectionsMu.Unlock()
|
||||
log.Printf("UI client disconnected")
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg map[string]interface{}
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
log.Printf("UI client disconnected: %v", err)
|
||||
log.Printf("UI client read error: %v", err)
|
||||
break
|
||||
}
|
||||
action, _ := msg["action"].(string)
|
||||
@@ -120,6 +161,42 @@ func startUI(dataDir, nodeID, serverAddr string) {
|
||||
}
|
||||
case "instances":
|
||||
listInstances(dataDir, conn)
|
||||
case "get_status":
|
||||
sendUIStatus(conn, dataDir)
|
||||
case "run_diagnostic":
|
||||
sendUIStatus(conn, dataDir)
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"action": "diagnostic_result",
|
||||
"status": buildUIStatus(dataDir),
|
||||
"message": "Diagnostic terminé",
|
||||
})
|
||||
case "get_logs":
|
||||
// Logs are streamed as they are produced; no persistent buffer yet.
|
||||
conn.WriteJSON(map[string]interface{}{
|
||||
"action": "log",
|
||||
"message": "Console prête. Les nouveaux logs apparaîtront ici.",
|
||||
"level": "info",
|
||||
})
|
||||
case "start_instance":
|
||||
instanceID, _ := msg["instanceId"].(string)
|
||||
if instanceID != "" {
|
||||
go uiStartInstance(dataDir, nodeID, instanceID)
|
||||
}
|
||||
case "stop_instance":
|
||||
instanceID, _ := msg["instanceId"].(string)
|
||||
if instanceID != "" {
|
||||
go uiStopInstance(dataDir, instanceID)
|
||||
}
|
||||
case "delete_instance":
|
||||
instanceID, _ := msg["instanceId"].(string)
|
||||
if instanceID != "" {
|
||||
go uiDeleteInstance(dataDir, instanceID)
|
||||
}
|
||||
case "reset_instance":
|
||||
instanceID, _ := msg["instanceId"].(string)
|
||||
if instanceID != "" {
|
||||
go uiResetInstance(dataDir, nodeID, instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -139,7 +216,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
var list []map[string]interface{}
|
||||
list := []map[string]interface{}{}
|
||||
for _, inst := range instances {
|
||||
status := getInstanceStatus(dataDir, inst.ID)
|
||||
if status != inst.Status {
|
||||
@@ -149,6 +226,7 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": inst.ID,
|
||||
"templateName": inst.TemplateName,
|
||||
"type": inst.TemplateName,
|
||||
"port": inst.Port,
|
||||
"status": inst.Status,
|
||||
"url": instanceURL(inst),
|
||||
@@ -157,3 +235,225 @@ func listInstances(dataDir string, conn *websocket.Conn) {
|
||||
|
||||
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": list})
|
||||
}
|
||||
|
||||
// sendUILog broadcasts a log line to all connected UI clients.
|
||||
func sendUILog(message string) {
|
||||
uiConnectionsMu.RLock()
|
||||
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||
for conn := range uiConnections {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
uiConnectionsMu.RUnlock()
|
||||
|
||||
msg := map[string]interface{}{
|
||||
"action": "log",
|
||||
"message": message,
|
||||
"level": "info",
|
||||
}
|
||||
for _, conn := range conns {
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
// Client may have disconnected; ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendInstanceProgress broadcasts a progress update for a specific instance.
|
||||
func sendInstanceProgress(instanceID, step, percent, message string) {
|
||||
broadcastUI(map[string]interface{}{
|
||||
"action": "progress",
|
||||
"instanceId": instanceID,
|
||||
"step": step,
|
||||
"percent": percent,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// broadcastUI sends a message to all connected UI clients.
|
||||
func broadcastUI(msg map[string]interface{}) {
|
||||
uiConnectionsMu.RLock()
|
||||
conns := make([]*websocket.Conn, 0, len(uiConnections))
|
||||
for conn := range uiConnections {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
uiConnectionsMu.RUnlock()
|
||||
|
||||
for _, conn := range conns {
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
// Ignore write errors for disconnected clients.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendUIStatus sends the current services status to a single UI connection.
|
||||
func sendUIStatus(conn *websocket.Conn, dataDir string) {
|
||||
if err := conn.WriteJSON(map[string]interface{}{
|
||||
"action": "status",
|
||||
"status": buildUIStatus(dataDir),
|
||||
}); err != nil {
|
||||
log.Printf("sendUIStatus error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildUIStatus constructs a user-friendly status snapshot.
|
||||
func buildUIStatus(dataDir string) map[string]interface{} {
|
||||
// Connection to the school server.
|
||||
connectionState := "pending"
|
||||
connectionDetail := "Connexion en cours..."
|
||||
mainConnMu.Lock()
|
||||
connected := mainConn != nil
|
||||
mainConnMu.Unlock()
|
||||
if connected {
|
||||
connectionState = "ok"
|
||||
connectionDetail = "Connecté au serveur de l'établissement"
|
||||
} else {
|
||||
connectionState = "error"
|
||||
connectionDetail = "Non connecté au serveur de l'établissement"
|
||||
}
|
||||
|
||||
// Application service (Docker/Podman + VPN).
|
||||
appServiceState := "pending"
|
||||
appServiceDetail := "Vérification du service d'applications..."
|
||||
engine := getContainerEngine()
|
||||
if engineAvailable(engine) {
|
||||
if isTailscaleRunning() {
|
||||
appServiceState = "ok"
|
||||
appServiceDetail = "Service d'applications prêt"
|
||||
} else {
|
||||
appServiceState = "warn"
|
||||
appServiceDetail = "Service d'applications disponible, connexion sécurisée en cours"
|
||||
}
|
||||
} else {
|
||||
appServiceState = "error"
|
||||
appServiceDetail = "Service d'applications non disponible"
|
||||
}
|
||||
|
||||
// Applications ready.
|
||||
applicationsState := "pending"
|
||||
applicationsDetail := "Vérification des applications..."
|
||||
if instances, err := loadInstances(dataDir); err == nil {
|
||||
ready := 0
|
||||
total := len(instances)
|
||||
for _, inst := range instances {
|
||||
if getInstanceStatus(dataDir, inst.ID) == "running" {
|
||||
ready++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
applicationsState = "ok"
|
||||
applicationsDetail = "Aucune application assignée"
|
||||
} else if ready == total {
|
||||
applicationsState = "ok"
|
||||
applicationsDetail = fmt.Sprintf("%d application%s prête%s", ready, plural(ready), plural(ready))
|
||||
} else if ready > 0 {
|
||||
applicationsState = "warn"
|
||||
applicationsDetail = fmt.Sprintf("%d / %d application%s prête%s", ready, total, plural(ready), plural(ready))
|
||||
} else {
|
||||
applicationsState = "pending"
|
||||
applicationsDetail = fmt.Sprintf("%d application%s en cours de démarrage", total, plural(total))
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"connection": connectionState,
|
||||
"connectionDetail": connectionDetail,
|
||||
"appService": appServiceState,
|
||||
"appServiceDetail": appServiceDetail,
|
||||
"applications": applicationsState,
|
||||
"applicationsDetail": applicationsDetail,
|
||||
}
|
||||
}
|
||||
|
||||
func engineAvailable(engine string) bool {
|
||||
_, err := exec.LookPath(engine)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n > 1 {
|
||||
return "s"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// uiStartInstance starts a stopped instance without recreating its containers,
|
||||
// so volumes and data are preserved.
|
||||
func uiStartInstance(dataDir, nodeID, instanceID string) {
|
||||
inst, err := loadInstances(dataDir)
|
||||
if err != nil || inst[instanceID] == nil {
|
||||
log.Printf("uiStartInstance: instance %s not found", instanceID)
|
||||
return
|
||||
}
|
||||
info := inst[instanceID]
|
||||
|
||||
if instanceContainersExist(dataDir, instanceID) {
|
||||
if err := dockerComposeStart(dataDir, instanceID); err != nil {
|
||||
log.Printf("uiStartInstance: start failed for %s: %v", instanceID, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := dockerComposeUp(dataDir, instanceID); err != nil {
|
||||
log.Printf("uiStartInstance: up failed for %s: %v", instanceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := setupTailscaleServe(info.Port); err != nil {
|
||||
log.Printf("uiStartInstance: setupTailscaleServe failed for %s: %v", instanceID, err)
|
||||
}
|
||||
|
||||
status := getInstanceStatus(dataDir, instanceID)
|
||||
info.Status = status
|
||||
_ = upsertInstance(dataDir, info)
|
||||
_ = sendMessage(WSMessage{Action: "instance_started", InstanceID: instanceID, Port: info.Port})
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
}
|
||||
|
||||
// uiStopInstance stops a running instance without removing its containers or volumes.
|
||||
func uiStopInstance(dataDir, instanceID string) {
|
||||
_ = dockerComposeStop(dataDir, instanceID)
|
||||
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
|
||||
inst[instanceID].Status = "stopped"
|
||||
_ = saveInstances(dataDir, inst)
|
||||
}
|
||||
_ = sendMessage(WSMessage{Action: "instance_stopped", InstanceID: instanceID})
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
}
|
||||
|
||||
// uiDeleteInstance removes an instance and its data (volumes included).
|
||||
func uiDeleteInstance(dataDir, instanceID string) {
|
||||
if inst, err := loadInstances(dataDir); err == nil && inst[instanceID] != nil {
|
||||
removeTailscaleServe(inst[instanceID].Port)
|
||||
}
|
||||
dockerComposeRm(dataDir, instanceID)
|
||||
removeInstance(dataDir, instanceID)
|
||||
_ = sendMessage(WSMessage{Action: "instance_deleted", InstanceID: instanceID})
|
||||
notifyUI(map[string]interface{}{"action": "instances_updated"})
|
||||
}
|
||||
|
||||
// uiResetInstance stops, removes volumes and recreates an instance from scratch.
|
||||
func uiResetInstance(dataDir, nodeID, instanceID string) {
|
||||
inst, err := loadInstances(dataDir)
|
||||
if err != nil || inst[instanceID] == nil {
|
||||
log.Printf("uiResetInstance: instance %s not found", instanceID)
|
||||
return
|
||||
}
|
||||
info := inst[instanceID]
|
||||
composePath := filepath.Join(instanceDir(dataDir, instanceID), "docker-compose.yml")
|
||||
composeBytes, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
log.Printf("uiResetInstance: cannot read compose for %s: %v", instanceID, err)
|
||||
return
|
||||
}
|
||||
dockerComposeRm(dataDir, instanceID)
|
||||
handleStartInstance(dataDir, nodeID, instanceID, info.TemplateName, string(composeBytes), info.Port)
|
||||
}
|
||||
|
||||
// instanceContainersExist returns true if compose containers already exist for this instance.
|
||||
func instanceContainersExist(dataDir, instanceID string) bool {
|
||||
dir := instanceDir(dataDir, instanceID)
|
||||
cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "ps", "-q")
|
||||
configureEngineCmd(cmd, dir)
|
||||
out, err := cmd.Output()
|
||||
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user