401 lines
15 KiB
Go
Executable File
401 lines
15 KiB
Go
Executable File
// 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)
|
|
}
|