Chapter 19: Building the Orchestrator UI
The Sovereign Orchestrator UI is a Single Page Application served by the Chapter 16 Go worker pool that operates across two simultaneous verification regimes: the WASM WAM executing inside the browser tab provides sub-millisecond logic feedback as the operator types, while every state-mutating action is independently re-parsed, re-validated, and re-executed by the server-side CGO pool before any cluster mutation is permitted — because the browser is not a trust boundary, and never has been. This chapter wires orchestrator_server.go to the Chapter 16 Pool, writes the dashboard.js coordination layer, defines the COOP/COEP and Cache-Control middleware directly in Go, and closes with the double-verification security contract that makes the WASM layer a performance optimisation rather than a security primitive.
19.1 The Architecture of the Sovereign SPA
19.1.1 Why Vanilla JS
A React application has a build-time dependency graph of 800–1,200 npm packages. Any one of those packages can introduce a supply-chain compromise, a breaking API change, or an incompatible peer dependency on a timeline with no relation to the operator's schedule. The compiled bundle is opaque JavaScript that requires a build toolchain to modify, a CI pipeline to deploy, and a browser devtools session to debug in production. Three years from now, node_modules will fail to compile against a Node.js version that ships with the host OS in 2029. The framework becomes a maintenance liability on a cadence controlled by the npm ecosystem, not by the operator.
Vanilla JavaScript with direct DOM manipulation has no build step, no dependency graph, no package., and no version drift. The source file that serves the UI today serves it in 2035. It loads in any browser released after 2018, it is readable without tooling, and it is debuggable with the browser's built-in inspector. Every API used in this chapter — json,jsonfetch,fetch, addEventListener,addEventListener, querySelector,querySelector, classList,classList, template literals, async/await — is in the HTML Living Standard and has been stable since 2020. The WASM runtime (swipl.) is the single external binary, versioned explicitly in wasm)wasmbuild.sh and served as an immutable cached assetasset.
The trade-off is real: no component model, no reactive state graph, no JSX. For a sovereign infrastructure dashboard used by a small operations team, not a consumer product, these are not trade-offs — they are constraints that were never requirements.
19.1.2 The Verification Loop
%%{init: {"themeVariables": {"fontSize": "14px"}}}%%
flowchart TD
INPUT["Operator Input\nIP address, port, protocol\nFree-text DOM fields\nNo server contact yet"]
WASM["WASM WAM — Browser Tab\nfirewall_verdict/4 executes locally\nmust_be(ground, Src) guard fires\nparse_ipv4/2 validates semantically\nLatency: 15-30μs\nResult: instant green/red UI feedback\nZero network round trips"]
DECISION{"Deploy to Cluster\nclicked?"}
FEEDBACK["Visual Feedback Only\nDOM class: allowed / denied\nNo state mutation\nNo server contact\nOperator continues editing"]
FETCH["fetch() POST — Go API\n/api/v1/firewall/check\nJSON: {source_ip, dest_port, protocol}\nAuthorization: Bearer token\nRaw DOM values only\nWASM verdict NOT forwarded"]
GOPOOL["Go Worker Pool — Server\nChapter 16: locked OS threads\nEach: independent WAM engine\nRe-parses JSON payload from scratch\nRe-runs firewall_verdict/4\nRe-fires must_be(ground, X) guards\nNo trust in any client assertion"]
AUTHCHECK["Authorization + Re-Validation\nBearer token verified server-side\nSourceIP: re-parsed by parse_ipv4/2\nPort: bounds check 1-65535\nProtocol: closed vocabulary atom check\nAll fields treated as hostile input"]
MUTATE["Cluster State Mutation\nretract_link / assert_link on shared DB\nControlFlushTables broadcast Ch16\nabolish_table_subgoals Ch17\nAudit log entry written\nReachable only after server re-validation"]
REJECT["Reject 400 / 403\nStructured JSON error to client\nWASM UI shows server rejection reason\nNo cluster mutation occurred\nAttempt recorded in audit log"]
INPUT --->|"on input event"| WASM
WASM --->|"<5ms result"| DECISION
DECISION --->|"still editing"| FEEDBACK
FEEDBACK --->|"back to typing"| INPUT
DECISION --->|"Deploy clicked"| FETCH
FETCH --->|"HTTP POST"| GOPOOL
GOPOOL --->|"token + payload"| AUTHCHECK
AUTHCHECK --->|"valid"| MUTATE
AUTHCHECK --->|"invalid"| REJECT
style INPUT fill:#1A2B4A,color:#FFFFFF
style WASM fill:#7A1A1A,color:#FFFFFF
style DECISION fill:#8B6914,color:#FFFFFF
style FEEDBACK fill:#2A4A2A,color:#FFFFFF
style FETCH fill:#1A4070,color:#FFFFFF
style GOPOOL fill:#1A4070,color:#FFFFFF
style AUTHCHECK fill:#5A1A6A,color:#FFFFFF
style MUTATE fill:#1A6B3A,color:#FFFFFF
style REJECT fill:#7A1A1A,color:#FFFFFF
The amber decision node is the architectural hinge. An input event on the IP field triggers the WASM branch — the WAM executes, the DOM is painted, no network contact occurs. A submit event on the Deploy button triggers the fetch branch — a fresh JSON payload constructed from the raw DOM values (not from the WASM result) is posted to the Go API, which re-validates every byte as if the client sent garbage.
19.2 Serving the Edge: Go net/http
19.2.1 Middleware for COOP/COEP and Cache-Control
Chapter 18 defined the nginx configuration for COOP/COEP headers and Cache-Control. When the Go binary serves static files directly — the correct posture for a sovereign single-binary deployment with no nginx dependency — these headers are injected by a Go middleware wrapper. The wrapper inspects r.URL.Path on each response and applies the policy appropriate to that file type:
// File: /opt/logic-node/go/orchestrator/middleware.go
package main
import (
"net/http"
"strings"
)
// secureHeaders injects headers required for:
//
// - COOP/COEP on all responses: Chrome and Firefox require these on every
// response from the origin — not just the HTML entry point — when
// SharedArrayBuffer is in use. Applying them at the mux root is correct.
//
// - Immutable caching on .wasm and .data: content-hash suffixes from
// build.sh (Chapter 18, §18.2.4.1) make these assets safe to cache
// permanently. One year max-age is the conventional signal to CDNs and
// browser caches that the resource never changes at this URL.
//
// - application/wasm MIME type: a server returning application/octet-stream
// for .wasm files forces the browser to buffer the full 8.3MB binary before
// compilation begins, eliminating the streaming compile optimisation in V8
// and SpiderMonkey that halves the effective load time.
//
// - no-cache on index.html, swipl.js, dashboard.js: these files embed or
// locate the current hashed asset filenames. A stale index.html referencing
// swipl.a1b2c3d4.wasm that was replaced in the last build produces a 404
// on the most expensive fetch in the page lifecycle.
func secureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
p := r.URL.Path
switch {
case strings.HasSuffix(p, ".wasm"):
w.Header().Set("Content-Type", "application/wasm")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case strings.HasSuffix(p, ".data"):
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case strings.HasSuffix(p, ".js") &&
!strings.HasSuffix(p, "dashboard.js") &&
!strings.HasSuffix(p, "swipl.js"):
// Hashed app JS assets not otherwise matched.
w.Header().Set("Cache-Control", "public, max-age=3600")
case p == "/" ||
strings.HasSuffix(p, "/index.html") ||
strings.HasSuffix(p, "dashboard.js") ||
strings.HasSuffix(p, "swipl.js"):
// Entry point and Emscripten glue: must never be served stale.
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
default:
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
// requireJSON returns 415 for POST/PUT requests without application/json
// Content-Type. The check precedes body reading.
func requireJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost || r.Method == http.MethodPut {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeJSON(w, http.StatusUnsupportedMediaType, APIResponse{
Error: "Content-Type must be application/json",
})
return
}
}
next.ServeHTTP(w, r)
})
}
// requestSizeLimit wraps r.Body in an http.MaxBytesReader.
// 64KB rejects any body structurally too large to be a legitimate payload;
// the largest realistic payload in this system is under 200 bytes.
func requestSizeLimit(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
// chain applies middleware in left-to-right declaration order.
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
19.2.2 Static File Handler with SPA Fallback
// File: /opt/logic-node/go/orchestrator/static.go
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
)
// staticHandler serves files from staticDir and falls back to index.html
// for any path that does not map to an existing file. The SPA fallback is
// required because deep links (/dashboard/topology) should load index.html
// and let the JS router handle the path rather than returning a 404.
func staticHandler(staticDir string) http.Handler {
fs := http.FileServer(http.Dir(staticDir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prohibit directory listings.
if strings.HasSuffix(r.URL.Path, "/") && r.URL.Path != "/" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
target := filepath.Join(staticDir, filepath.Clean(r.URL.Path))
if _, err := os.Stat(target); os.IsNotExist(err) {
r.URL.Path = "/"
}
fs.ServeHTTP(w, r)
})
}
19.3 The Build: orchestrator_server.go
// File: /opt/logic-node/go/orchestrator/orchestrator_server.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"runtime"
"syscall"
"time"
)
// ─────────────────────────────────────────────────────────────────────────────
// REQUEST / RESPONSE TYPES
// ─────────────────────────────────────────────────────────────────────────────
type FirewallCheckReq struct {
SourceIP string `json:"source_ip"`
DestPort int `json:"dest_port"`
Protocol string `json:"protocol"`
}
type TopologyMutateReq struct {
Action string `json:"action"` // "add_link" | "remove_link"
Node1 string `json:"node1"`
Node2 string `json:"node2"`
Cost int `json:"cost"`
}
type APIResponse struct {
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Latency int64 `json:"latency_us,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, body APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("[API] encode error: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SERVER
// ─────────────────────────────────────────────────────────────────────────────
type Server struct {
pool *Pool
http *http.Server
staticDir string
}
func NewServer(pool *Pool, staticDir, addr string) *Server {
s := &Server{pool: pool, staticDir: staticDir}
mux := http.NewServeMux()
apiGuards := func(h http.HandlerFunc) http.Handler {
return chain(h, requireJSON, requestSizeLimit(64*1024))
}
mux.Handle("/api/v1/firewall/check", apiGuards(s.handleFirewallCheck))
mux.Handle("/api/v1/topology/mutate", apiGuards(s.handleTopologyMutate))
mux.HandleFunc("/api/v1/pool/status", s.handlePoolStatus)
mux.Handle("/", staticHandler(staticDir))
s.http = &http.Server{
Addr: addr,
Handler: secureHeaders(mux),
// ReadHeaderTimeout: protects against Slowloris attacks.
// An attacker opens a TCP connection and sends HTTP headers one byte
// at a time, permanently tying up a Go connection goroutine per open
// connection. With enough connections the server runs out of goroutines.
// ReadTimeout alone does not reliably close this window across all Go
// versions — the timeout begins when the connection is accepted, but
// early Go runtime implementations reset it on first-byte receipt,
// leaving a gap between TCP accept and the first header byte.
// ReadHeaderTimeout is specifically the duration allowed to read ALL
// request headers after the connection is accepted. It is the correct
// defence for bare-metal Go servers on hostile networks.
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
return s
}
func (s *Server) ListenAndServe() error {
log.Printf("[Server] Listening on %s — static: %s", s.http.Addr, s.staticDir)
if err := s.http.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// ─────────────────────────────────────────────────────────────────────────────
// GRACEFUL SHUTDOWN
// ─────────────────────────────────────────────────────────────────────────────
// Shutdown stops accepting HTTP connections first, then drains the worker pool.
//
// Order is mandatory. Stopping the pool before the HTTP server means in-flight
// HTTP handlers call pool.Dispatch into a stopped pool and receive ErrPoolStopped,
// producing 503 responses for requests that arrived before the signal and
// should have been served. Stopping HTTP first prevents new dispatches from
// arriving while the pool drains.
func (s *Server) Shutdown(ctx context.Context) {
log.Printf("[Server] Shutdown: draining HTTP…")
if err := s.http.Shutdown(ctx); err != nil {
log.Printf("[Server] HTTP shutdown: %v", err)
}
log.Printf("[Server] Shutdown: draining pool…")
if err := s.pool.Stop(10 * time.Second); err != nil {
log.Printf("[Server] Pool stop: %v", err)
}
log.Printf("[Server] Shutdown complete")
}
// ─────────────────────────────────────────────────────────────────────────────
// HANDLERS
// ─────────────────────────────────────────────────────────────────────────────
// handleFirewallCheck: POST /api/v1/firewall/check
//
// The server dispatches to the WAM pool and returns the authoritative verdict.
// The client's WASM result — if one was computed — is unknown to this handler
// and is not accepted as input. The server has no interest in what the browser
// computed; it computes its own answer from the raw payload fields.
//
// LRU cache layer (firewallCache, see firewall_cache.go):
// Before dispatching to the WAM pool, the handler checks an in-process LRU
// cache keyed on (SourceIP, DestPort, Protocol). A cache hit returns the
// stored verdict in O(1) without acquiring a WAM worker thread.
// The cache only stores denied verdicts — an allowed IP that is later
// blocked by a KB update must not be served a stale cached "allowed" result.
// A denied verdict is immutable in the context of a DDoS: the attacker's IP
// is not going to become permitted between requests. Cache entries expire
// after 60 seconds so that a KB update adding the IP to a whitelist takes
// effect within one TTL window rather than requiring a cache flush.
func (s *Server) handleFirewallCheck(w http.ResponseWriter, r *http.Request) {
var req FirewallCheckReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, APIResponse{
Error: fmt.Sprintf("malformed JSON: %v", err),
})
return
}
// LRU cache check — denied verdicts only.
// Allowed verdicts are never cached: a policy change blocking a previously
// allowed IP must take effect immediately at the server. The WAM pool
// is the authority for allowed determinations; the cache is strictly
// for amplification defence against known-denied sources.
cacheKey := fmt.Sprintf("%s:%d:%s", req.SourceIP, req.DestPort, req.Protocol)
if cached, ok := firewallCache.Get(cacheKey); ok {
v := cached.(CachedVerdict)
writeJSON(w, http.StatusOK, APIResponse{
OK: true,
Data: map[string]interface{}{
"allowed": false,
"reason": v.Reason,
"rule_id": v.RuleID,
},
Latency: 0, // sub-microsecond RAM lookup — not meaningful to report
})
return
}
// Goal string construction: SourceIP and Protocol are embedded as
// double-quoted Prolog strings (not atoms). escapeProlog handles
// backslash and double-quote. The WAM's must_be/parse_ipv4 guards
// perform semantic validation inside the WAM — that is their job.
goal := fmt.Sprintf(
`firewall_verdict(request{source_ip:"%s",dest_port:%d,protocol:"%s"},Verdict,Reason,RuleID)`,
escapeProlog(req.SourceIP),
req.DestPort,
escapeProlog(req.Protocol),
)
result, err := s.pool.Dispatch(WorkItem{Goal: goal}, 500*time.Millisecond)
if err != nil {
log.Printf("[API] firewall/check dispatch: %v", err)
writeJSON(w, http.StatusServiceUnavailable, APIResponse{
Error: "worker pool unavailable",
})
return
}
if result.Err != nil {
writeJSON(w, http.StatusBadRequest, APIResponse{
Error: result.Err.Error(),
})
return
}
verdict := result.Bindings["Verdict"] == "allowed"
// Cache denied verdicts for 60 seconds.
if !verdict {
firewallCache.Add(cacheKey, CachedVerdict{
Reason: result.Bindings["Reason"],
RuleID: result.Bindings["RuleID"],
})
}
writeJSON(w, http.StatusOK, APIResponse{
OK: true,
Data: map[string]interface{}{
"allowed": verdict,
"reason": result.Bindings["Reason"],
"rule_id": result.Bindings["RuleID"],
},
Latency: result.Duration.Microseconds(),
})
}
// handleTopologyMutate: POST /api/v1/topology/mutate
//
// Cluster-mutating endpoint. Node names are validated against a Go-level
// closed vocabulary (knownTopologyNode) before any goal string is assembled —
// making goal injection via the node1/node2 fields structurally impossible.
// On success, broadcasts ControlFlushTables to invalidate Chapter 17 tabled
// shortest_path/3 answers across all 16 worker engines.
func (s *Server) handleTopologyMutate(w http.ResponseWriter, r *http.Request) {
var req TopologyMutateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, APIResponse{
Error: fmt.Sprintf("malformed JSON: %v", err),
})
return
}
if !knownTopologyNode(req.Node1) || !knownTopologyNode(req.Node2) {
writeJSON(w, http.StatusBadRequest, APIResponse{
Error: fmt.Sprintf("unknown topology node: %q or %q", req.Node1, req.Node2),
})
return
}
if req.Cost < 0 || req.Cost > 1_000_000 {
writeJSON(w, http.StatusBadRequest, APIResponse{
Error: fmt.Sprintf("cost out of range: %d", req.Cost),
})
return
}
var goal string
switch req.Action {
case "add_link":
goal = fmt.Sprintf("proxmox_topology:assert_link(%s,%s,%d)",
req.Node1, req.Node2, req.Cost)
case "remove_link":
goal = fmt.Sprintf("proxmox_topology:retract_link(%s,%s,%d)",
req.Node1, req.Node2, req.Cost)
default:
writeJSON(w, http.StatusBadRequest, APIResponse{
Error: fmt.Sprintf("unknown action: %q", req.Action),
})
return
}
result, err := s.pool.Dispatch(WorkItem{Goal: goal}, 2*time.Second)
if err != nil || result.Err != nil {
msg := ""
if err != nil {
msg = err.Error()
} else {
msg = result.Err.Error()
}
writeJSON(w, http.StatusInternalServerError, APIResponse{Error: msg})
return
}
// Mutation succeeded on the shared clause database.
// Purge the denial cache first: a topology change may alter firewall verdict
// outcomes for IPs evaluated against routing rules. Stale denials must not
// survive a KB mutation. Purge before broadcast — the cache is invalid the
// moment the clause database changes, not after workers acknowledge it.
firewallCache.Purge()
// Invalidate Chapter 17 tabled answers across all worker engines.
s.pool.Broadcast(ControlMsg{Kind: ControlFlushTables})
writeJSON(w, http.StatusOK, APIResponse{
OK: true,
Data: map[string]string{
"status": "topology_updated",
"tables": "invalidated",
},
})
}
func (s *Server) handlePoolStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, APIResponse{OK: true, Data: s.pool.Status()})
}
// ─────────────────────────────────────────────────────────────────────────────
// INPUT SANITISATION
// ─────────────────────────────────────────────────────────────────────────────
// escapeProlog escapes backslash and double-quote characters in s so that s
// can be safely embedded inside a double-quoted Prolog string in a goal
// constructed via fmt.Sprintf. Prolog double-quoted string metacharacters:
// \ → \\
// " → \"
// For SourceIP and Protocol the upstream validation and closed vocabulary
// checks ensure neither character appears. escapeProlog is the backstop for
// direct API calls that bypass the UI.
func escapeProlog(s string) string {
out := make([]byte, 0, len(s)+4)
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\\' || c == '"' {
out = append(out, '\\')
}
out = append(out, c)
}
return string(out)
}
// ─────────────────────────────────────────────────────────────────────────────
// FIREWALL VERDICT CACHE
// ─────────────────────────────────────────────────────────────────────────────
// File: /opt/logic-node/go/orchestrator/firewall_cache.go
//
// A layer-7 DDoS attack using the same hostile source IP sends thousands of
// requests per second. Without a cache, the WAM pool executes firewall_verdict/4
// for every request, consuming a worker thread for 20-40μs per query. At 1,000
// req/s from a single attacker IP, the pool spends 20-40ms of aggregate WAM
// time per second serving an IP that was denied on the first query and will be
// denied on every subsequent query as long as the KB is unchanged.
//
// The LRU cache short-circuits this: once an IP is confirmed denied by the WAM,
// subsequent requests for the same (IP, Port, Protocol) triple are served from
// RAM without any WAM interaction. The worker pool is preserved strictly for
// novel (IP, Port, Protocol) combinations — inputs that genuinely require
// reasoning, not lookups.
//
// Cache design decisions:
// - Denied-only: allowed verdicts are never cached. A policy change that
// blocks a previously allowed IP must take effect at the next request.
// The WAM is the authority for allowed determinations; the cache is
// a denial amplification defence, not an allow bypass.
// - TTL-based expiry via a thin wrapper: hashicorp/golang-lru provides an
// LRU eviction policy (size-bounded). For TTL, a CachedVerdict carries
// a cachedAt timestamp; entries older than cacheTTL are treated as misses.
// This avoids the complexity of a separate TTL heap while preserving
// the O(1) Get/Add operations of the underlying LRU.
// - Cache invalidation on KB reload: handleTopologyMutate calls
// firewallCache.Purge() after a successful mutation broadcast.
// A topology change that alters routing rules may alter firewall verdicts
// indirectly (a blocked-by-route rule becomes active). Purging the denial
// cache on any KB mutation is the conservative, correct behaviour.
// - Size ceiling: 4,096 entries × ~200 bytes/entry ≈ 800KB in-process RAM.
// A /24 subnet attack (256 IPs × 65,535 ports) cannot fill the cache;
// LRU eviction handles it within the size bound.
// go.mod addition required: github.com/hashicorp/golang-lru/v2
package main
import (
"sync"
"time"
lru "github.com/hashicorp/golang-lru/v2"
)
const (
cacheTTL = 60 * time.Second
cacheSize = 4096
)
// CachedVerdict stores a denied verdict with its WAM derivation metadata
// and the timestamp at which it was cached.
type CachedVerdict struct {
Reason string
RuleID string
CachedAt time.Time
}
// firewallCacheWrapper wraps an LRU cache with a TTL check.
// It exposes the same Get/Add/Purge interface used in handleFirewallCheck
// and handleTopologyMutate.
type firewallCacheWrapper struct {
mu sync.RWMutex
inner *lru.Cache[string, CachedVerdict]
}
func newFirewallCache() *firewallCacheWrapper {
c, _ := lru.New[string, CachedVerdict](cacheSize)
return &firewallCacheWrapper{inner: c}
}
// Get returns (verdict, true) if the key is present and the cached verdict
// is younger than cacheTTL. Expired entries are evicted on access.
func (fc *firewallCacheWrapper) Get(key string) (CachedVerdict, bool) {
fc.mu.RLock()
v, ok := fc.inner.Get(key)
fc.mu.RUnlock()
if !ok {
return CachedVerdict{}, false
}
if time.Since(v.CachedAt) > cacheTTL {
// Expired — evict and report miss.
fc.mu.Lock()
fc.inner.Remove(key)
fc.mu.Unlock()
return CachedVerdict{}, false
}
return v, true
}
// Add stores a denied verdict. Only called after the WAM confirms denial.
func (fc *firewallCacheWrapper) Add(key string, v CachedVerdict) {
v.CachedAt = time.Now()
fc.mu.Lock()
fc.inner.Add(key, v)
fc.mu.Unlock()
}
// Purge evicts all entries. Called on KB mutation to prevent stale denials.
func (fc *firewallCacheWrapper) Purge() {
fc.mu.Lock()
fc.inner.Purge()
fc.mu.Unlock()
}
// firewallCache is the package-level singleton, initialised in main().
// Declared as a pointer so that main() can call newFirewallCache() after
// pool initialisation, making the initialisation order explicit.
var firewallCache *firewallCacheWrapper
The firewallCache.Purge() call on topology mutation belongs in handleTopologyMutate, immediately before the ControlFlushTables broadcast:
// Purge the denial cache: a topology mutation may alter firewall verdict
// outcomes for IPs that were previously evaluated against a routing rule.
// The WAM pool is the authority; stale cached denials must not persist
// across a KB change.
firewallCache.Purge()
// Broadcast ControlFlushTables: invalidate Chapter 17 tabled answers.
s.pool.Broadcast(ControlMsg{Kind: ControlFlushTables})
And in main(), initialise the cache after pool creation:
pool, err := NewPool(poolSize, kbPath)
if err != nil {
log.Fatalf("[Main] Pool init: %v", err)
}
// Firewall denial cache — initialised after pool, before server start.
firewallCache = newFirewallCache()
log.Printf("[Main] Firewall denial cache: size=%d TTL=%s", cacheSize, cacheTTL)
The cache never caches allowed verdicts. It never bypasses the WAM for a novel (IP, Port, Protocol) triple. It never survives a KB mutation. Its sole function is to prevent a known-denied source from consuming a WAM worker on every repeated request — preserving the Chapter 16 pool strictly for inputs that require reasoning.
// ─────────────────────────────────────────────────────────────────────────────
// MAIN
// ─────────────────────────────────────────────────────────────────────────────
func main() { staticDir := envOr("STATIC\_DIR", "/opt/logic-node/wasm-ui/dist") kbPath := envOr("KB\_PATH", "/opt/logic-node/kb/firewall\_policy.pl") listenOn := envOr("LISTEN\_ADDR", ":8080")
// Pool size: GOMAXPROCS - 2.
// Reserve one logical CPU for the Go scheduler + runtime goroutines and
// one for the HTTP listener. On a 16-core Proxmox node: 14 WAM workers.
poolSize := runtime.GOMAXPROCS(0) - 2
if poolSize < 1 {
poolSize = 1
}
log.Printf("[Main] Pool: %d workers KB: %s", poolSize, kbPath)
pool, err := NewPool(poolSize, kbPath)
if err != nil {
log.Fatalf("[Main] Pool init: %v", err)
}
// Firewall denial cache — initialised after pool, before server start.
// Denied-only; purged on every KB mutation (handleTopologyMutate).
firewallCache = newFirewallCache()
log.Printf("[Main] Firewall denial cache ready: size=%d TTL=%s", cacheSize, cacheTTL)
// Build the topology node vocabulary from the live WAM clause database.
// Must run after pool init — requires a Dispatch into the initialised pool.
if err := buildKnownNodes(pool); err != nil {
log.Printf("[Main] WARNING: known_node vocabulary: %v", err)
}
srv := NewServer(pool, staticDir, listenOn)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
if err := srv.ListenAndServe(); err != nil {
log.Printf("[Main] server: %v", err)
}
}()
log.Printf("[Main] Ready — http://%s/", listenOn)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
}
func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def }
19.4 The Build: `dashboard.js`js
// File: /opt/logic-node/wasm-ui/src/dashboard.js
//
// COORDINATION CONTRACT:
//
// WASM WAM (browser): fires on every 'input' event — read-only, no server
// contact, paints the UI green or red in <5ms.
//
// Go API (server): fires on 'submit' — receives raw DOM values, re-validates
// everything from scratch, returns the authoritative verdict.
// The JS never forwards the WASM result to the server.
// The server doesn't accept it.
//
// ATOM TABLE CONTRACT (Chapter 18 §18.5):
// All user-controlled DOM values cross the WASM boundary as Prolog strings
// (double-quoted in the goal template literal). Never single-quoted atoms.
// Never bare unquoted values. double_quotes=string is verified on init.
'use strict';
const API = '/api/v1';
const PROTOCOLS = new Set(['tcp', 'udp', 'icmp']);
const MAX_IP = 45; // longest valid IPv6 string representation
// ─────────────────────────────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────────────────────────────
const wasm = {
module: null,
ready: false,
failed: false,
lastVerdict: null, // {allowed, reason, latencyUs} from most recent edge query
};
// ─────────────────────────────────────────────────────────────────────────────
// WASM INIT
// ─────────────────────────────────────────────────────────────────────────────
async function initWasm() {
setStatus('Loading WAM engine…', 'neutral');
try {
wasm.module = await SWIPL({
arguments: [
'swipl', '--quiet',
'--stack-limit=8M',
'--table-space=4M',
'-g', 'true', '-t', 'halt',
],
locateFile: (f) => `./${f}`,
});
// Verify double_quotes=string before permitting any user query.
// If the flag is 'atom', every IP string typed by the operator is
// interned permanently in the Atom Table. 100,000 keystrokes → 9MB
// of permanent Atom Table growth → WASM OOM trap → tab dead.
const flagResult = wasm.module.Prolog.query(
'current_prolog_flag(double_quotes,F)'
).once();
if (!flagResult || flagResult.F !== 'string') {
throw new Error(
`double_quotes='${flagResult?.F}', must be 'string' — Atom Table at risk`
);
}
const ok = wasm.module.Prolog.call("consult('/prolog/firewall_policy.pl')");
if (!ok) throw new Error('consult failed — verify VFS packaging in build.sh');
wasm.ready = true;
setStatus('WAM engine ready — zero-latency edge validation active.', 'ok');
} catch (err) {
wasm.failed = true;
setStatus(`WAM unavailable: ${err.message}. Server validation only.`, 'warn');
console.error('[WASM] Init failed:', err);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WASM LIVE CHECK — fires on every input event
// ─────────────────────────────────────────────────────────────────────────────
// runWasmCheck is the hot path. At 15-30μs per WAM query there is no latency
// cost to calling it on every keystroke. No debounce is applied or needed.
function runWasmCheck(ip, port, protocol) {
if (!wasm.ready || wasm.failed) { clearVerdict(); return; }
// JS-layer pre-validation: format check before the WASM boundary crossing.
// This is a UX guard — it rejects inputs structurally incapable of being
// valid IP addresses before spending 20μs on the WAM.
// It is not a security boundary. The WAM's parse_ipv4/2 and must_be guards
// are the security boundary.
if (!ipv4Valid(ip) || port < 1 || port > 65535 || !PROTOCOLS.has(protocol)) {
clearVerdict();
return;
}
// Goal construction — strings, never atoms.
// ip and protocol are validated above: [0-9./] and closed vocabulary.
// No Prolog metacharacters are possible in these specific inputs.
// double_quotes=string (verified on init) means "ip" and "protocol"
// below are Prolog string objects on the WAM global heap — GC-eligible.
// They are NOT interned in the Atom Table.
const goal =
`firewall_verdict(` +
`request{source_ip:"${ip}",dest_port:${port},protocol:"${protocol}"},` +
`Verdict,Reason,_)`;
try {
const t0 = performance.now();
const res = wasm.module.Prolog.query(goal).once();
const lat = Math.round((performance.now() - t0) * 1000);
if (res === null) { clearVerdict(); return; }
wasm.lastVerdict = {
allowed: res.Verdict === 'allowed',
reason: res.Reason,
latencyUs: lat,
};
paintVerdict(wasm.lastVerdict, 'edge');
} catch (err) {
console.warn('[WASM] Live check error:', err.message);
if (isUnrecoverable(err)) {
wasm.failed = true;
setStatus('Edge WAM crashed — server validation only.', 'warn');
}
clearVerdict();
}
}
function isUnrecoverable(err) {
const m = err.message || '';
return m.includes('RuntimeError') ||
m.includes('memory access out of bounds') ||
m.includes('out_of_stack') ||
m.includes('unreachable');
}
// ─────────────────────────────────────────────────────────────────────────────
// GO API SUBMISSION — fires only on Deploy button click
// ─────────────────────────────────────────────────────────────────────────────
// submitToServer constructs a fresh JSON payload from the raw DOM values.
// It does NOT read wasm.lastVerdict or forward it to the server.
// The server has no knowledge of what the browser's WASM computed,
// and does not accept that information as input.
async function submitToServer(ip, port, protocol) {
setDeployState('loading');
try {
const resp = await fetch(`${API}/firewall/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({ source_ip: ip, dest_port: port, protocol }),
signal: AbortSignal.timeout(5_000),
});
const body = await resp.json();
if (!resp.ok || !body.ok) {
setDeployResult('error', body.error || `Server error ${resp.status}`);
return;
}
const sv = {
allowed: body.data.allowed,
reason: body.data.reason,
latencyUs: body.latency_us,
};
// Discrepancy detection: if the edge WAM ran and disagrees with the
// server, the disagreement is logged and shown as a diagnostic banner.
// The server verdict is authoritative. The banner is informational —
// the security invariant is already enforced by the server re-validation
// regardless of what the browser computed.
if (wasm.lastVerdict !== null &&
wasm.lastVerdict.allowed !== sv.allowed) {
console.warn(
`[Dashboard] Discrepancy — edge:${wasm.lastVerdict.allowed}` +
` server:${sv.allowed}`
);
showDiscrepancy(wasm.lastVerdict, sv);
}
paintVerdict(sv, 'server');
setDeployResult('success',
`${sv.allowed ? 'ALLOWED' : 'DENIED'} — ${sv.reason} [${sv.latencyUs}μs server]`
);
} catch (err) {
const msg = err.name === 'TimeoutError'
? 'Server timeout — pool may be saturated.'
: `Network error: ${err.message}`;
setDeployResult('error', msg);
} finally {
setDeployState('idle');
}
}
// ─────────────────────────────────────────────────────────────────────────────
// TOPOLOGY MUTATION
// ─────────────────────────────────────────────────────────────────────────────
async function submitTopologyMutation() {
const action = qs('#mutate-action')?.value;
const node1 = qs('#node1')?.value.trim() ?? '';
const node2 = qs('#node2')?.value.trim() ?? '';
const cost = parseInt(qs('#link-cost')?.value ?? '0', 10);
try {
const resp = await fetch(`${API}/topology/mutate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({ action, node1, node2, cost }),
signal: AbortSignal.timeout(10_000),
});
const body = await resp.json();
if (!resp.ok || !body.ok) {
setDeployResult('error', `Mutation failed: ${body.error}`);
return;
}
setDeployResult('success',
'Cluster topology updated. Tabled answers invalidated across all workers.'
);
// Server broadcast ControlFlushTables. The browser WASM KB is now stale.
scheduleEdgeKbRefresh();
} catch (err) {
setDeployResult('error', `Mutation error: ${err.message}`);
}
}
// scheduleEdgeKbRefresh: flushes the topology tables in the WASM instance
// and re-consults to resync the edge KB with the mutated server-side KB.
// Runs 500ms after the mutation to allow the pool broadcast to settle.
function scheduleEdgeKbRefresh() {
setTimeout(() => {
if (!wasm.module || wasm.failed) return;
try {
wasm.ready = false;
wasm.module.Prolog.call('flush_topology_tables');
wasm.module.Prolog.call("consult('/prolog/firewall_policy.pl')");
wasm.ready = true;
setStatus('Edge KB refreshed.', 'ok');
} catch (err) {
wasm.failed = true;
setStatus('Edge KB refresh failed — server only.', 'warn');
}
}, 500);
}
// ─────────────────────────────────────────────────────────────────────────────
// DOM HELPERS
// ─────────────────────────────────────────────────────────────────────────────
const qs = (sel) => document.querySelector(sel);
function paintVerdict(v, source) {
const el = qs('#verdict');
if (!el) return;
el.className = `verdict ${v.allowed ? 'allowed' : 'denied'}`;
el.textContent =
`${v.allowed ? 'ALLOWED' : 'DENIED'} — ${h(v.reason)} [${source} ${v.latencyUs}μs]`;
}
function clearVerdict() {
const el = qs('#verdict');
if (el) { el.className = 'verdict'; el.textContent = ''; }
}
function setStatus(msg, level) {
const el = qs('#status');
if (el) { el.textContent = msg; el.className = `status ${level}`; }
}
function setDeployState(st) {
const btn = qs('#deploy-btn');
if (!btn) return;
btn.disabled = st === 'loading';
btn.textContent = st === 'loading' ? 'Deploying…' : 'Deploy to Cluster';
}
function setDeployResult(level, msg) {
const el = qs('#deploy-result');
if (el) { el.className = `deploy-result ${level}`; el.textContent = h(msg); }
}
function showDiscrepancy(edgeV, serverV) {
const el = qs('#discrepancy-banner');
if (!el) return;
el.hidden = false;
el.textContent =
`Edge KB returned ${edgeV.allowed ? 'ALLOWED' : 'DENIED'} ` +
`but server returned ${serverV.allowed ? 'ALLOWED' : 'DENIED'}. ` +
`KB propagation in progress — server verdict is authoritative.`;
}
function getToken() {
return sessionStorage.getItem('auth_token') ?? '';
}
function ipv4Valid(ip) {
return ip.length > 0 &&
ip.length <= MAX_IP &&
/^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
}
function h(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])
);
}
// ─────────────────────────────────────────────────────────────────────────────
// EVENT WIRING
// ─────────────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initWasm();
const ipEl = qs('#src-ip');
const portEl = qs('#dest-port');
const protoEl = qs('#protocol');
function onAnyChange() {
const ip = ipEl?.value.trim() ?? '';
const port = parseInt(portEl?.value ?? '0', 10);
const proto = protoEl?.value ?? 'tcp';
runWasmCheck(ip, port, proto);
}
ipEl?.addEventListener('input', onAnyChange);
portEl?.addEventListener('input', onAnyChange);
protoEl?.addEventListener('change', onAnyChange);
// Authoritative server path — fires on Deploy.
qs('#check-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const ip = ipEl?.value.trim() ?? '';
const port = parseInt(portEl?.value ?? '0', 10);
const proto = protoEl?.value ?? 'tcp';
await submitToServer(ip, port, proto);
});
qs('#mutate-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
await submitTopologyMutation();
});
});
19.5 Security: The Double-Verification Contract
19.5.1 The Bypass Threat Model
The WASM layer executes entirely inside the attacker's environment. A curl command, a Burp Suite intercept, or a malicious browser extension can construct any JSON payload and POST it directly to /api/v1/topology/mutate with a valid bearer token, bypassing every line of JavaScript and every WAM guard the browser tab contains:
# Any operator — or any attacker with a valid token — can do this right now.
# No browser. No JavaScript. No WASM. The Go HTTP server receives this payload
# with no indication that it did not originate from the sovereign dashboard.
curl -X POST http://logic-node-01.infra.internal/api/v1/topology/mutate \
-H "Authorization: Bearer $(cat ~/.sovereign_token)" \
-H "Content-Type: application/json" \
-d '{
"action": "add_link",
"node1": "pve1",
"node2": "attacker_pivot",
"cost": 0
}'
If handleTopologyMutate trusts the JSON payload — if it constructs a Prolog goal from req.Node1 without first verifying that attacker_pivot is a known cluster node — then attacker_pivot is added to the topology, shortest_path/3 routes traffic through it, and the logic-based routing is compromised without any WASM code running at all.
The WASM validation is a latency optimisation. It moves the error notification 5ms earlier in the operator's experience. It is not a security gate, because gates that can be walked around are not gates.
19.5.2 Server-Side Re-Validation Architecture
The five re-validation layers applied to every mutation request, in execution order:
Layer 1 — requestSizeLimit(64 * 1024)
The body is truncated at 64KB before a single byte is parsed.
A multi-megabyte payload — crafted JSON designed to exhaust the Go decoder,
a deeply nested structure, a malformed UTF-8 sequence — is rejected at the
middleware level before any business logic runs.
Cost: O(1) header read.
Layer 2 — json.NewDecoder().Decode(&req)
The body is parsed into a typed Go struct. Unknown JSON keys are silently
discarded. A payload with fabricated extra fields is reduced to the four
expected fields. Type mismatches — a string where int is expected — return
an error before the handler runs. The Go type system is the first semantic
filter.
Layer 3 — knownTopologyNode(req.Node1) && knownTopologyNode(req.Node2)
Node names are checked against a frozen Go map (knownNodes) built at server
startup by querying the WAM's known_node/1 predicate via pool.Dispatch.
"attacker_pivot" is not in known_node/1. It never passes Layer 3.
No goal string is assembled until both node names pass this check.
Goal injection via the node name fields is structurally impossible: the
goal string is built only from names that existed in the WAM clause database
at the last server startup.
Layer 4 — Goal string construction
The goal string is assembled exclusively from validated tokens:
req.Node1 and req.Node2 have passed Layer 3.
req.Cost is a Go int with an explicit 0–1,000,000 bounds check.
req.Action is matched against a closed switch — any other value returns 400
before fmt.Sprintf is called.
escapeProlog() handles backslash and double-quote for any remaining edge cases.
Layer 5 — WAM execution (CGO worker pool)
The Prolog goal executes in one of the 16 locked OS-thread workers.
assert_link/3 in proxmox_topology.pl calls must_be(ground, N1),
must_be(ground, N2), and must_be(positive_integer, Cost) — the same
Chapter 17 guards that protect every WAM deployment context.
A malformed term that survived Layers 1–4 is caught here and returned as
a Prolog type_error, surfaced as a 400 response. No cluster mutation occurs.
The closed vocabulary is built at startup and frozen:
// File: /opt/logic-node/go/orchestrator/topology.go
package main
import (
"fmt"
"log"
"sync"
"time"
)
var (
knownNodesMu sync.RWMutex
knownNodes = make(map[string]struct{})
)
// buildKnownNodes populates knownNodes by querying the WAM clause database.
// Must be called after NewPool — requires a live Dispatch.
//
// The map is frozen after startup. Nodes added to the Prolog DB at runtime
// via handleTopologyMutate are intentionally excluded — they have not been
// reviewed in a server restart cycle and are not eligible as mutation targets
// via the REST API until they appear in the KB at the next startup.
func buildKnownNodes(pool *Pool) error {
result, err := pool.Dispatch(WorkItem{
Goal: "findall(N,proxmox_topology:known_node(N),Nodes)",
}, 2*time.Second)
if err != nil {
return fmt.Errorf("dispatch: %w", err)
}
if result.Err != nil {
return fmt.Errorf("WAM: %w", result.Err)
}
knownNodesMu.Lock()
defer knownNodesMu.Unlock()
for _, node := range result.NodeList {
knownNodes[node] = struct{}{}
}
log.Printf("[Security] known_node vocabulary: %d nodes", len(knownNodes))
return nil
}
func knownTopologyNode(name string) bool {
knownNodesMu.RLock()
defer knownNodesMu.RUnlock()
_, ok := knownNodes[name]
return ok
}
19.5.3 The Verdict Discrepancy as a Diagnostic
When dashboard.js detects wasm.lastVerdict.allowed !== sv.allowed, it logs the discrepancy and shows the diagnostic banner. Two legitimate causes exist: the server KB was reloaded between the edge query and the API call (a topology mutation is propagating), or the WASM instance loaded a cached .qlf that predates a recent policy change.
One illegitimate cause exists: the WASM instance was tampered with — a patched swipl.wasm in the browser cache, a modified firewall_policy.qlf, or a browser extension reversing the verdict before it reaches the JS handler. In this case, the server-side WAM returns the correct verdict and the cluster mutation is governed by that verdict regardless of what the browser computed. The discrepancy banner is an operator diagnostic that surfaces KB propagation lag; it is not a security alert, because the security invariant is enforced by the server before the banner is ever shown.
The security model has one statement: the server CGO worker pool is the sole authority on whether a cluster mutation is permitted. Every architectural decision across Chapters 15 through 19 has been made in service of that statement.