Files
Agent-System/internal/engineeringdashboard/server.go
T
weichenandCursor e251ce325a feat(engineering-dashboard): U6-U7 serve UI and offline export
Add loopback-only HTTP server with startup token, Host/Origin/CSP
guards, embedded live UI, CLI serve/export, and safe single-file HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 07:08:27 +00:00

219 lines
6.0 KiB
Go

package engineeringdashboard
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Server is the local read-only HTTP surface.
type Server struct {
Collector *Collector
Token string
mu sync.Mutex
}
// NewServer creates a server with a random startup token.
func NewServer(c *Collector) (*Server, error) {
tok, err := randomToken(16)
if err != nil {
return nil, err
}
return &Server{Collector: c, Token: tok}, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Handler returns the HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/api/v1/snapshot", s.handleSnapshot)
mux.HandleFunc("/api/v1/doc/", s.handleDoc)
mux.HandleFunc("/", s.handleStatic)
return s.secure(mux)
}
func (s *Server) secure(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "..") || strings.Contains(r.URL.RawPath, "..") || strings.Contains(r.URL.RawQuery, "..") {
http.Error(w, "path traversal denied", http.StatusForbidden)
return
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
http.Error(w, "host not allowed", http.StatusForbidden)
return
}
origin := r.Header.Get("Origin")
if origin != "" && !isLocalOrigin(origin) {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
site := r.Header.Get("Sec-Fetch-Site")
if site == "cross-site" {
http.Error(w, "cross-site fetch denied", http.StatusForbidden)
return
}
// token required for API and page (query or header)
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/assets") {
tok := r.URL.Query().Get("token")
if tok == "" {
tok = r.Header.Get("X-Dashboard-Token")
}
if tok != s.Token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func isLocalOrigin(origin string) bool {
return strings.HasPrefix(origin, "http://127.0.0.1") ||
strings.HasPrefix(origin, "http://localhost") ||
strings.HasPrefix(origin, "http://[::1]")
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
snap := s.Collector.Current()
if snap == nil || r.URL.Query().Get("refresh") == "1" {
var err error
snap, err = s.Collector.Refresh(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
} else {
// soft refresh if TTL expired
if time.Since(snap.GeneratedAt) > s.Collector.TTL {
go func() { _, _ = s.Collector.Refresh(ctx) }()
}
}
etag := `"` + snap.RevisionID + `"`
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
raw, err := MarshalSnapshotJSON(*snap)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(raw)
}
func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/v1/doc/")
id = strings.TrimPrefix(id, "/")
if id == "" {
http.Error(w, "missing doc id", http.StatusBadRequest)
return
}
if _, err := SafeDocPath(s.Collector.RepoRoot, id); err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
snap := s.Collector.Current()
if snap == nil {
var err error
snap, err = s.Collector.Refresh(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
}
for _, d := range snap.Documents {
if d.ID == id || d.Path == id {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(d)
return
}
}
http.Error(w, "not found", http.StatusNotFound)
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch path {
case "/", "/index.html":
b, err := readAsset("assets/index.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
html := strings.ReplaceAll(string(b), "__TOKEN__", s.Token)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
case "/assets/app.js":
b, err := readAsset("assets/app.js")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write(b)
case "/assets/app.css":
b, err := readAsset("assets/app.css")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write(b)
default:
http.NotFound(w, r)
}
}
// ListenAndServe binds loopback-only.
func ListenAndServe(addr string, h http.Handler) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("refusing non-loopback bind address: %s", addr)
}
ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err != nil {
return err
}
return http.Serve(ln, h)
}