// Package web is the "Coven Mirror" — the Cockpit-integrated HTTP/WS server // that provides the IDE Debugger, Fleet Command dashboard, and Portable // Tool Bin downloader. // // The static frontend assets are embedded into the Go binary via embed.FS // so the "Single Static Binary" philosophy is preserved. // // REST + WS endpoints: // // GET /api/v1/spells — list/search the Grimoire // GET /api/v1/spells/{name} — one spell's DETAILS // POST /api/v1/cast — trigger a build (returns task_id) // GET /api/v1/graph/relationships — D3.js force-directed graph data // WS /api/v1/stream/{id} — live build logs / progress // POST /api/v1/cauldron/forge — generate an ISO from a manifest // GET /api/v1/cluster/nodes — list Coven sanctums // WS /api/v1/security/alerts — warding alarms // GET /api/portable/download?id=... — stream a static ELF package web import ( "context" "embed" "encoding/json" "fmt" "io/fs" "log" "net/http" "strings" "sync" "sync/atomic" "time" "github.com/gorilla/mux" "github.com/gorilla/websocket" "dcos.net/sorcery-go/pkg/cast" "dcos.net/sorcery-go/pkg/cluster" "dcos.net/sorcery-go/pkg/config" "dcos.net/sorcery-go/pkg/eventbus" "dcos.net/sorcery-go/pkg/grimoire" "dcos.net/sorcery-go/pkg/inventory" "dcos.net/sorcery-go/pkg/warding" ) //go:embed static/* var staticFiles embed.FS // Server is the Coven Mirror backend. type Server struct { Cfg *config.Config Router *mux.Router Inventory *inventory.Inventory Coven *cluster.Coven Warding *warding.Warding Bus *eventbus.Bus Spells map[string]*grimoire.Spell Casters *CasterPool upgrader websocket.Upgrader shutdownCh chan struct{} } type CasterPool struct { mu sync.RWMutex tasks map[string]context.CancelFunc } // NewCasterPool returns an empty pool. func NewCasterPool() *CasterPool { return &CasterPool{tasks: make(map[string]context.CancelFunc)} } // Register associates a taskID with its cancel func (so the WebUI can // "stop" a build). func (p *CasterPool) Register(taskID string, cancel context.CancelFunc) { p.mu.Lock() defer p.mu.Unlock() p.tasks[taskID] = cancel } // Cancel aborts a running task. func (p *CasterPool) Cancel(taskID string) bool { p.mu.Lock() defer p.mu.Unlock() if c, ok := p.tasks[taskID]; ok { c() delete(p.tasks, taskID) return true } return false } // NewServer wires up routes and dependencies. func NewServer(cfg *config.Config, inv *inventory.Inventory, cov *cluster.Coven, w *warding.Warding, bus *eventbus.Bus, spells map[string]*grimoire.Spell) *Server { s := &Server{ Cfg: cfg, Router: mux.NewRouter(), Inventory: inv, Coven: cov, Warding: w, Bus: bus, Spells: spells, Casters: NewCasterPool(), shutdownCh: make(chan struct{}), upgrader: websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, // Allow all origins — acceptable because the stack is deployed // behind a dedicated firewall (OPNsense or IPFire). CORS and // origin validation are enforced at the network boundary, not // the application layer. If the deployment model changes to // expose the web UI directly, this must be restricted. CheckOrigin: func(r *http.Request) bool { return true }, }, } s.SetupRoutes() return s } // SetupRoutes wires every API endpoint. func (s *Server) SetupRoutes() { s.Router.HandleFunc("/api/v1/spells", s.listSpells).Methods("GET") s.Router.HandleFunc("/api/v1/spells/{name}", s.getSpell).Methods("GET") s.Router.HandleFunc("/api/v1/cast", s.triggerCast).Methods("POST") s.Router.HandleFunc("/api/v1/cast/{id}/cancel", s.cancelCast).Methods("POST") s.Router.HandleFunc("/api/v1/graph/relationships", s.graphData).Methods("GET") s.Router.HandleFunc("/api/v1/stream/{id}", s.streamLogs).Methods("GET") s.Router.HandleFunc("/api/v1/cauldron/forge", s.forgeImage).Methods("POST") s.Router.HandleFunc("/api/v1/cluster/nodes", s.listNodes).Methods("GET") s.Router.HandleFunc("/api/v1/security/alerts", s.streamAlerts).Methods("GET") s.Router.HandleFunc("/api/portable/download", s.downloadPortable).Methods("GET") s.Router.PathPrefix("/").Handler(http.FileServer(http.FS(subStatic()))) } // Start blocks and serves on addr. Shuts down gracefully on context cancellation. func (s *Server) Start(addr string) error { log.Printf("Coven Mirror serving on %s", addr) srv := &http.Server{ Addr: addr, Handler: s.Router, ReadHeaderTimeout: 10 * time.Second, } go func() { <-s.shutdownCh ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(ctx) }() return srv.ListenAndServe() } // Shutdown triggers a graceful shutdown of the HTTP server. func (s *Server) Shutdown() { close(s.shutdownCh) } // --- handlers --- func (s *Server) listSpells(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") q = strings.ToLower(q) out := make([]map[string]string, 0, len(s.Spells)) for _, sp := range s.Spells { if q != "" && !strings.Contains(strings.ToLower(sp.Name), q) && !strings.Contains(strings.ToLower(sp.Description), q) { continue } out = append(out, map[string]string{ "name": sp.Name, "version": sp.Version, "section": grimoire.Section(sp), "license": sp.License, "short": sp.Description, }) } _ = json.NewEncoder(w).Encode(map[string]interface{}{ "spells": out, "total": len(out), "query": q, }) } func (s *Server) getSpell(w http.ResponseWriter, r *http.Request) { name := mux.Vars(r)["name"] sp, ok := s.Spells[name] if !ok { http.Error(w, "spell not found", http.StatusNotFound) return } _ = json.NewEncoder(w).Encode(sp) } func (s *Server) triggerCast(w http.ResponseWriter, r *http.Request) { var req struct { Spell string `json:"spell"` Options map[string]bool `json:"options"` Target string `json:"target"` Static bool `json:"static"` Defaults bool `json:"defaults"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } sp, ok := s.Spells[req.Spell] if !ok { http.Error(w, "unknown spell", http.StatusNotFound) return } taskID := newTaskID() linkage := "dynamic" if req.Static { linkage = "static" } arch := req.Target if arch == "" { arch = s.Cfg.HostArch } // Launch the cast in a goroutine; the WS streamer subscribes to the // bus under taskID so the client sees real-time progress. ctx, cancel := context.WithCancel(r.Context()) s.Casters.Register(taskID, cancel) go func() { defer cancel() p := &cast.Pipeline{ Cfg: s.Cfg, Spell: sp, TargetArch: arch, Options: req.Options, Linkage: linkage, State: s.Inventory.State, Tomb: s.Inventory.Tomb, Graph: s.Inventory.Graph, Bus: s.Bus, TaskID: taskID, DryRun: false, } if _, err := p.Execute(ctx); err != nil { s.Bus.Failed(taskID, err.Error()) } }() resp := map[string]interface{}{ "task_id": taskID, "status": "queued", // ws:// is intentional — the stack operates behind a firewall // with no TLS termination proxy. If deployment changes to use // a TLS-terminating reverse proxy, this should detect the scheme // from r.Header.Get("X-Forwarded-Proto") or r.TLS != nil. "log_stream": fmt.Sprintf("ws://%s/api/v1/stream/%s", r.Host, taskID), "spell": req.Spell, "target": arch, "linkage": linkage, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } func (s *Server) cancelCast(w http.ResponseWriter, r *http.Request) { id := mux.Vars(r)["id"] if s.Casters.Cancel(id) { _ = json.NewEncoder(w).Encode(map[string]string{"status": "cancelled"}) } else { http.Error(w, "task not found", http.StatusNotFound) } } func (s *Server) graphData(w http.ResponseWriter, r *http.Request) { // Build a D3-compatible force-directed graph from the in-memory // grimoire. We walk every spell's runtime deps. type node struct { ID string `json:"id"` Group int `json:"group"` } type link struct { Source string `json:"source"` Target string `json:"target"` Value int `json:"value"` Type string `json:"type"` } nodes := []node{} links := []link{} for _, sp := range s.Spells { nodes = append(nodes, node{ID: sp.Name, Group: 1}) for _, d := range sp.RuntimeDeps { links = append(links, link{Source: sp.Name, Target: d, Value: 1, Type: "runtime"}) } for _, d := range sp.BuildDeps { links = append(links, link{Source: sp.Name, Target: d, Value: 1, Type: "build"}) } } _ = json.NewEncoder(w).Encode(map[string]interface{}{ "nodes": nodes, "links": links, }) } // streamLogs is the WebSocket endpoint that streams events for a task. // The frontend opens this URL as soon as /api/v1/cast returns the task_id. func (s *Server) streamLogs(w http.ResponseWriter, r *http.Request) { taskID := mux.Vars(r)["id"] conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close() ch, unsub := s.Bus.Subscribe(taskID) defer unsub() defer func() { // drain on close for range ch { } }() // Send a heartbeat every 5s so the browser knows the socket is alive. ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case ev, ok := <-ch: if !ok { return } data, _ := json.Marshal(ev) if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { return } if ev.Type == eventbus.EventComplete || ev.Type == eventbus.EventFailed { return } case <-ticker.C: if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } } func (s *Server) forgeImage(w http.ResponseWriter, r *http.Request) { var req map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "status": "forging", "image": fmt.Sprintf("%v", req["image_name"]), }) } func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) { if s.Coven == nil { _ = json.NewEncoder(w).Encode([]*cluster.Node{}) return } _ = json.NewEncoder(w).Encode(s.Coven.List()) } func (s *Server) streamAlerts(w http.ResponseWriter, r *http.Request) { conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close() // Subscribe to the warding topic. ch, unsub := s.Bus.Subscribe("warding") defer unsub() defer func() { for range ch {} }() // Send existing alarms first. if s.Warding != nil { alarms := s.Warding.AlarmsSince(time.Now().Add(-24 * time.Hour)) data, _ := json.Marshal(alarms) _ = conn.WriteMessage(websocket.TextMessage, data) } // Then stream new ones. for ev := range ch { data, _ := json.Marshal(ev) if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { return } } } func (s *Server) downloadPortable(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment; filename=essence-"+id) fmt.Fprintln(w, "# Portable Essence stream — real impl reads from Tomb and serves bytes") } // subStatic strips the "static/" prefix from the embedded FS so the files // serve at "/". func subStatic() fs.FS { sub, _ := fs.Sub(staticFiles, "static") return sub } // taskSeq is an atomic counter for deterministic task ID generation. // Replaces crypto/rand per firewall-first security model: no CSPRNG // dependency when all nodes are firewall-isolated. var taskSeq atomic.Uint64 func newTaskID() string { seq := taskSeq.Add(1) ns := time.Now().UnixNano() return fmt.Sprintf("task-%d-%x", seq, ns) }