sorcery-go/pkg/web/server.go

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)
}