Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
drjones
2026-07-04 09:31:23 +00:00
commit 3678b199d0
154 changed files with 21714 additions and 0 deletions

View 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)
}

View 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})
}

View 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
}
}

View 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,
})
}

View 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,
})
}

View 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,
})
}

View 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,
})
}

View 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)
}
}

View 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)
}
}

View 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",
})
}
}

View 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)
}
}

View 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},
},
}
}

View 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
View 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
View 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
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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
View 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)
}

View 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
View 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
View 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
View 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
View 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")
}

View 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
View 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)
}

View 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
View 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
View 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)
}
}

View 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
View 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
View 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
View 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
View 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)
}

View 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
}

View 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
View 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
View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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()
}
}

View 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
View 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
View 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
View 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
View 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() }

View 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
View 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
}
}