Files
edubox/agent/ui.go
T
EduBox Dev a414f03a59 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.
2026-06-25 22:59:09 +00:00

460 lines
14 KiB
Go

package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
//go:embed ui/index.html
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")
fmt.Fprint(w, uiHTML)
})
http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
cfg, _, err := loadOrCreateConfig(dataDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if cfg.DataDir == "" {
cfg.DataDir = dataDir
}
if err := saveConfig(dataDir, &cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusNoContent)
go func() {
cmd := exec.Command(os.Args[0], os.Args[1:]...)
hideWindow(cmd)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
log.Printf("Restart failed: %v", err)
return
}
os.Exit(0)
}()
})
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("UI WS upgrade error: %v", err)
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{}) {
if err := conn.WriteJSON(msg); err != nil {
log.Printf("UI notify error: %v", err)
}
})
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 read error: %v", err)
break
}
action, _ := msg["action"].(string)
log.Printf("UI received action: %s", action)
switch action {
case "check":
act, err := loadActivation(dataDir)
if err == nil && act.Activated {
conn.WriteJSON(map[string]interface{}{"action": "activated", "studentName": act.StudentName})
} else {
conn.WriteJSON(map[string]interface{}{"action": "not_activated"})
}
case "activate":
code, _ := msg["code"].(string)
log.Printf("UI handling activate with code: %s", code)
if err := sendMessage(WSMessage{Action: "activate", NodeID: nodeID, Code: code}); err != nil {
log.Printf("UI sendMessage failed: %v", err)
conn.WriteJSON(map[string]interface{}{"action": "activation_failed", "error": err.Error()})
} else {
log.Printf("UI sendMessage succeeded, waiting for server response...")
}
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)
}
}
}
})
port := "7070"
log.Printf("%s UI starting on http://localhost:%s", APP_NAME, port)
if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil {
log.Fatalf("%s UI server error: %v", APP_NAME, err)
}
}
func listInstances(dataDir string, conn *websocket.Conn) {
instances, err := loadInstances(dataDir)
if err != nil {
log.Printf("loadInstances error: %v", err)
conn.WriteJSON(map[string]interface{}{"action": "instances_list", "instances": []interface{}{}})
return
}
list := []map[string]interface{}{}
for _, inst := range instances {
status := getInstanceStatus(dataDir, inst.ID)
if status != inst.Status {
inst.Status = status
_ = upsertInstance(dataDir, inst)
}
list = append(list, map[string]interface{}{
"id": inst.ID,
"templateName": inst.TemplateName,
"type": inst.TemplateName,
"port": inst.Port,
"status": inst.Status,
"url": instanceURL(inst),
})
}
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)) != ""
}