Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
This commit is contained in:
37
.github/workflows/test.yml
vendored
Normal file
37
.github/workflows/test.yml
vendored
Normal 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
25
.gitignore
vendored
Normal 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
54
Makefile
Normal 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
538
README.md
Normal 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
0
cmd/agent/.gitkeep
Normal file
462
cmd/agent/main.go
Normal file
462
cmd/agent/main.go
Normal 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) }
|
||||||
134
cmd/agent/tier_chain_test.go
Normal file
134
cmd/agent/tier_chain_test.go
Normal 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
160
cmd/agent/update.go
Normal 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
67
cmd/agent/update_test.go
Normal 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
277
cmd/agent/ws_test.go
Normal 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
0
cmd/forge/.gitkeep
Normal file
122
cmd/forge/main.go
Normal file
122
cmd/forge/main.go
Normal 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
241
cmd/forge/main_test.go
Normal 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
97
cmd/server/main.go
Normal 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
206
cmd/server/main_test.go
Normal 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
|
||||||
|
}
|
||||||
1
cmd/server/webroot/assets/index-BJ9Sa22q.css
Normal file
1
cmd/server/webroot/assets/index-BJ9Sa22q.css
Normal file
File diff suppressed because one or more lines are too long
232
cmd/server/webroot/assets/index-C7yIT9Qp.js
Normal file
232
cmd/server/webroot/assets/index-C7yIT9Qp.js
Normal file
File diff suppressed because one or more lines are too long
5
cmd/server/webroot/forge.svg
Normal file
5
cmd/server/webroot/forge.svg
Normal 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 |
20
cmd/server/webroot/index.html
Normal file
20
cmd/server/webroot/index.html
Normal 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
34
data/config.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
14
deploy/systemd/forge-mesh-agent.service
Normal file
14
deploy/systemd/forge-mesh-agent.service
Normal 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
|
||||||
14
deploy/systemd/forge-mesh-server.service
Normal file
14
deploy/systemd/forge-mesh-server.service
Normal 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
46
docs/PROBLEMS.md
Normal 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
89
docs/TESTING.md
Normal 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
15
go.mod
Normal 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
13
go.sum
Normal 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=
|
||||||
98
internal/alerts/telegram.go
Normal file
98
internal/alerts/telegram.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds Telegram bot settings from config.json.
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier sends operator alerts via Telegram Bot API.
|
||||||
|
type Notifier struct {
|
||||||
|
cfg Config
|
||||||
|
client *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Notifier {
|
||||||
|
return &Notifier{
|
||||||
|
cfg: cfg,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether Telegram alerts are configured.
|
||||||
|
func (n *Notifier) Enabled() bool {
|
||||||
|
return n != nil && n.cfg.Enabled && n.cfg.BotToken != "" && n.cfg.ChatID != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send posts a message to the configured chat (best-effort, non-blocking caller).
|
||||||
|
func (n *Notifier) Send(text string) {
|
||||||
|
if !n.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := n.sendSync(text); err != nil {
|
||||||
|
log.Printf("telegram alert: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) sendSync(text string) error {
|
||||||
|
n.mu.Lock()
|
||||||
|
if time.Since(n.last) < 500*time.Millisecond {
|
||||||
|
n.mu.Unlock()
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
n.mu.Lock()
|
||||||
|
}
|
||||||
|
n.last = time.Now()
|
||||||
|
n.mu.Unlock()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", n.cfg.BotToken)
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"chat_id": n.cfg.ChatID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := n.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("telegram API status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FleetEvent formats a fleet alert message.
|
||||||
|
func FleetEvent(action, hostID, detail string) string {
|
||||||
|
return fmt.Sprintf("🔧 *forge-mesh* · `%s`\nhost: `%s`\n%s", action, hostID, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrucibleEvent formats a batch terminal alert.
|
||||||
|
func CrucibleEvent(jobID string, hostCount int, cmd string) string {
|
||||||
|
return fmt.Sprintf("⚗️ *crucible batch* · `%s`\nhosts: %d\ncmd: `%s`", jobID, hostCount, cmd)
|
||||||
|
}
|
||||||
28
internal/api/handlers/auth.go
Normal file
28
internal/api/handlers/auth.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthHandlers serves login and WS ticket endpoints.
|
||||||
|
type AuthHandlers struct {
|
||||||
|
Tickets *auth.TicketStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandlers) WSTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, err := h.Tickets.Issue()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "ticket error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"ticket": ticket})
|
||||||
|
}
|
||||||
136
internal/api/handlers/crucible.go
Normal file
136
internal/api/handlers/crucible.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge-mesh/internal/alerts"
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CrucibleHandler serves batch terminal endpoints.
|
||||||
|
type CrucibleHandler struct {
|
||||||
|
Store *fleet.Store
|
||||||
|
Hub *fleet.Hub
|
||||||
|
Crucible *fleet.CrucibleStore
|
||||||
|
Alerts *alerts.Notifier
|
||||||
|
OperatorClearance int
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchRequest struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
HostIDs []string `json:"host_ids"`
|
||||||
|
All bool `json:"all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CrucibleHandler) Dispatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := fleet.CheckAction(h.OperatorClearance, fleet.ActionCrucible); err != nil {
|
||||||
|
auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req batchRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Command = strings.TrimSpace(req.Command)
|
||||||
|
if req.Command == "" {
|
||||||
|
http.Error(w, "command required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostIDs := req.HostIDs
|
||||||
|
if req.All || len(hostIDs) == 0 {
|
||||||
|
hosts, err := h.Store.ListHosts()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, host := range hosts {
|
||||||
|
if host.Status == "online" || host.Status == "mining" {
|
||||||
|
hostIDs = append(hostIDs, host.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job := h.Crucible.Create(req.Command, hostIDs)
|
||||||
|
action := mapCommandToAction(req.Command)
|
||||||
|
|
||||||
|
for _, hostID := range hostIDs {
|
||||||
|
host, _ := h.Store.GetHost(hostID)
|
||||||
|
hostname := hostID
|
||||||
|
if host != nil {
|
||||||
|
hostname = host.Hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fleet.CheckAction(h.OperatorClearance, action); err != nil {
|
||||||
|
h.Crucible.AddResult(job.ID, fleet.BatchResult{
|
||||||
|
HostID: hostID, Hostname: hostname,
|
||||||
|
Status: "denied", Message: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := h.Hub.DispatchCommand(hostID, action, map[string]any{"raw": req.Command})
|
||||||
|
if err != nil {
|
||||||
|
h.Crucible.AddResult(job.ID, fleet.BatchResult{
|
||||||
|
HostID: hostID, Hostname: hostname,
|
||||||
|
Status: "error", Message: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Crucible.AddResult(job.ID, fleet.BatchResult{
|
||||||
|
HostID: hostID, Hostname: hostname,
|
||||||
|
Status: "dispatched", CommandID: cmd.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Crucible.Complete(job.ID, "completed")
|
||||||
|
|
||||||
|
if h.Alerts != nil && h.Alerts.Enabled() {
|
||||||
|
h.Alerts.Send(alerts.CrucibleEvent(job.ID, len(hostIDs), req.Command))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = h.Store.InsertSeerEvent("", "crucible", fmt.Sprintf(`{"job_id":"%s","command":%q}`, job.ID, req.Command))
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CrucibleHandler) GetJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
job, ok := h.Crucible.Get(id)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CrucibleHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"jobs": h.Crucible.History(20),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCommandToAction(cmd string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(cmd))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "pause"):
|
||||||
|
return fleet.ActionPause
|
||||||
|
case strings.HasPrefix(lower, "resume"):
|
||||||
|
return fleet.ActionResume
|
||||||
|
case strings.HasPrefix(lower, "reboot"):
|
||||||
|
return fleet.ActionReboot
|
||||||
|
case strings.HasPrefix(lower, "screenshot"):
|
||||||
|
return fleet.ActionScreenshot
|
||||||
|
case strings.HasPrefix(lower, "shell"):
|
||||||
|
return fleet.ActionShell
|
||||||
|
default:
|
||||||
|
return fleet.ActionStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
62
internal/api/handlers/dropper.go
Normal file
62
internal/api/handlers/dropper.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DropperHandler serves the dropper URL and one-liner info to the operator.
|
||||||
|
type DropperHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
PublicKeyHex string
|
||||||
|
FleetSecret string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropperInfo struct {
|
||||||
|
DropperURL string `json:"dropper_url"`
|
||||||
|
InstallURL string `json:"install_url"`
|
||||||
|
OneLiner string `json:"one_liner"`
|
||||||
|
FleetSecret string `json:"fleet_secret"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
HasBuild bool `json:"has_build"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func deckURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info handles GET /api/v1/dropper — returns dropper link, one-liner, and build status.
|
||||||
|
func (h *DropperHandler) Info(w http.ResponseWriter, r *http.Request) {
|
||||||
|
base := deckURL(r)
|
||||||
|
installURL := base + "/install.sh"
|
||||||
|
dropperURL := base + "/get"
|
||||||
|
|
||||||
|
oneLiner := fmt.Sprintf(
|
||||||
|
"FORGE_MESH_FLEET_SECRET=%s bash <(curl -fsSL %s)",
|
||||||
|
h.FleetSecret, installURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check whether a public build exists so the UI can warn if not.
|
||||||
|
_, err := forge.LatestPublic(h.DB, "linux", "amd64")
|
||||||
|
hasBuild := err == nil
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, DropperInfo{
|
||||||
|
DropperURL: dropperURL,
|
||||||
|
InstallURL: installURL,
|
||||||
|
OneLiner: oneLiner,
|
||||||
|
FleetSecret: h.FleetSecret,
|
||||||
|
PublicKey: h.PublicKeyHex,
|
||||||
|
HasBuild: hasBuild,
|
||||||
|
Version: h.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
532
internal/api/handlers/extended.go
Normal file
532
internal/api/handlers/extended.go
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/erasure"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
"forge-mesh/internal/policy"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErasureHandler serves public erasure shard endpoints.
|
||||||
|
type ErasureHandler struct {
|
||||||
|
Service *erasure.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ErasureHandler) GetBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bundleID := r.PathValue("bundle_id")
|
||||||
|
if bundleID == "" {
|
||||||
|
http.Error(w, "bundle_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bundle, err := h.Service.GetBundle(r.Context(), bundleID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indices, _ := h.Service.ListShards(r.Context(), bundleID)
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"bundle": bundle,
|
||||||
|
"shards": indices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ErasureHandler) GetShard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bundleID := r.PathValue("bundle_id")
|
||||||
|
indexStr := r.PathValue("index")
|
||||||
|
if bundleID == "" || indexStr == "" {
|
||||||
|
http.Error(w, "bundle_id and index required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var index int
|
||||||
|
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil {
|
||||||
|
http.Error(w, "invalid shard index", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shard, err := h.Service.GetShard(r.Context(), bundleID, index)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"bundle_id": shard.BundleID,
|
||||||
|
"shard_index": shard.ShardIndex,
|
||||||
|
"hex": shard.Hex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicySnapshotHandler serves degraded-agent policy snapshots.
|
||||||
|
type PolicySnapshotHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /api/v1/policy/snapshot (protected).
|
||||||
|
func (h *PolicySnapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
policy := map[string]any{
|
||||||
|
"wallet_policy": map[string]string{"currency": "XMR"},
|
||||||
|
"policy_from_server": true,
|
||||||
|
"version": "1",
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(policy)
|
||||||
|
token := uuid.NewString()
|
||||||
|
if err := SeedPolicySnapshot(r.Context(), h.DB, token, string(raw)); err != nil {
|
||||||
|
http.Error(w, "snapshot failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"url": "/api/v1/public/policy-snapshot/" + token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PolicySnapshotHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "token required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var policyJSON string
|
||||||
|
var expires sql.NullString
|
||||||
|
err := h.DB.QueryRowContext(r.Context(), `
|
||||||
|
SELECT policy_json, expires_at FROM policy_snapshots WHERE token = ?`, token).
|
||||||
|
Scan(&policyJSON, &expires)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if expires.Valid && expires.String != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", expires.String); err == nil && time.Now().After(t) {
|
||||||
|
http.Error(w, "expired", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(policyJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackCampaign handles GET /api/v1/public/campaign/track?c=CODE.
|
||||||
|
func TrackCampaign(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.URL.Query().Get("c")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "c required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackCampaignHeat(r.Context(), db, code)
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{"code": code, "tracked": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpreadLander serves the Emberwake public funnel page with ?c= heat tracking.
|
||||||
|
func SpreadLander(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
campaign := r.URL.Query().Get("c")
|
||||||
|
if campaign != "" {
|
||||||
|
trackCampaignHeat(r.Context(), db, campaign)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
install := "/install.sh"
|
||||||
|
if campaign != "" {
|
||||||
|
install += "?c=" + campaign
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Forge Mesh Spread</title>
|
||||||
|
<meta charset="utf-8"><style>body{font-family:system-ui;background:#0a0e14;color:#e6edf3;padding:2rem;max-width:640px;margin:auto}
|
||||||
|
a{color:#58d1ff}</style></head>
|
||||||
|
<body><h1>Emberwake Spread</h1><p>Campaign: <strong>%s</strong></p>
|
||||||
|
<p><a href="%s">Summon enrolled agent</a></p></body></html>`, campaign, install)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackCampaignHeat(ctx context.Context, db *sql.DB, code string) {
|
||||||
|
if db == nil || code == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT id FROM campaigns WHERE code = ?`, code).Scan(&id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
_, _ = db.ExecContext(ctx, `INSERT INTO campaigns (id, code, name, heat) VALUES (?, ?, ?, 1)`,
|
||||||
|
uuid.NewString(), code, code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
_, _ = db.ExecContext(ctx, `UPDATE campaigns SET heat = heat + 1 WHERE id = ?`, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarRoomHandler serves campaign heat dashboards.
|
||||||
|
type WarRoomHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WarRoomHandler) ListCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.QueryContext(r.Context(), `
|
||||||
|
SELECT id, code, name, COALESCE(pin,''), heat, created_at
|
||||||
|
FROM campaigns ORDER BY heat DESC, created_at DESC LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var campaigns []types.Campaign
|
||||||
|
for rows.Next() {
|
||||||
|
var c types.Campaign
|
||||||
|
var created string
|
||||||
|
if err := rows.Scan(&c.ID, &c.Code, &c.Name, &c.Pin, &c.Heat, &created); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||||
|
campaigns = append(campaigns, c)
|
||||||
|
}
|
||||||
|
if campaigns == nil {
|
||||||
|
campaigns = []types.Campaign{}
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{"campaigns": campaigns})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WireGuardHandler manages mesh peer records (operator-managed configs).
|
||||||
|
type WireGuardHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WireGuardHandler) ListPeers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.QueryContext(r.Context(), `
|
||||||
|
SELECT id, COALESCE(host_id,''), public_key, COALESCE(endpoint,''), allowed_ips, created_at
|
||||||
|
FROM wireguard_peers ORDER BY created_at DESC LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type peer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
HostID string `json:"host_id,omitempty"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
AllowedIPs string `json:"allowed_ips"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
var peers []peer
|
||||||
|
for rows.Next() {
|
||||||
|
var p peer
|
||||||
|
if err := rows.Scan(&p.ID, &p.HostID, &p.PublicKey, &p.Endpoint, &p.AllowedIPs, &p.CreatedAt); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peers = append(peers, p)
|
||||||
|
}
|
||||||
|
if peers == nil {
|
||||||
|
peers = []peer{}
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{"peers": peers})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WireGuardHandler) CreatePeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
AllowedIPs string `json:"allowed_ips"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PublicKey == "" {
|
||||||
|
http.Error(w, "public_key required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.AllowedIPs == "" {
|
||||||
|
req.AllowedIPs = "10.66.66.2/32"
|
||||||
|
}
|
||||||
|
id := uuid.NewString()
|
||||||
|
_, err := h.DB.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO wireguard_peers (id, host_id, public_key, endpoint, allowed_ips)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
id, nullIfEmpty(req.HostID), req.PublicKey, nullIfEmpty(req.Endpoint), req.AllowedIPs)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "create failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"host_id": req.HostID,
|
||||||
|
"public_key": req.PublicKey,
|
||||||
|
"endpoint": req.Endpoint,
|
||||||
|
"allowed_ips": req.AllowedIPs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderConfig handles GET /api/v1/wireguard/config — wg-quick template for operators.
|
||||||
|
func (h *WireGuardHandler) RenderConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.QueryContext(r.Context(), `
|
||||||
|
SELECT public_key, COALESCE(endpoint,''), allowed_ips FROM wireguard_peers ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
buf.WriteString("[Interface]\nPrivateKey = <operator-private-key>\nAddress = 10.66.66.1/24\nListenPort = 51820\n\n")
|
||||||
|
for rows.Next() {
|
||||||
|
var pub, endpoint, allowed string
|
||||||
|
if err := rows.Scan(&pub, &endpoint, &allowed); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.WriteString("[Peer]\n")
|
||||||
|
fmt.Fprintf(&buf, "PublicKey = %s\n", pub)
|
||||||
|
if endpoint != "" {
|
||||||
|
fmt.Fprintf(&buf, "Endpoint = %s\n", endpoint)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "AllowedIPs = %s\n\n", allowed)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(buf.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) sql.NullString {
|
||||||
|
if s == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOTLTimeline returns deploy audit entries for a host.
|
||||||
|
func (h *FleetHandler) LOTLTimeline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("id")
|
||||||
|
if hostID == "" {
|
||||||
|
http.Error(w, "host id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempts, err := h.Store.ListLOTL(r.Context(), hostID, 100)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"host_id": hostID,
|
||||||
|
"timeline": attempts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMiningProfile assigns a mining profile to a host.
|
||||||
|
func (h *FleetHandler) PushMiningProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("id")
|
||||||
|
if hostID == "" {
|
||||||
|
http.Error(w, "host id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req types.MiningProfileRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.WalletAddress == "" {
|
||||||
|
http.Error(w, "wallet_address required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
profileID := req.ProfileID
|
||||||
|
if profileID == "" {
|
||||||
|
profileID = uuid.NewString()
|
||||||
|
}
|
||||||
|
name := req.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "Fleet mining profile"
|
||||||
|
}
|
||||||
|
tiers := req.Tiers
|
||||||
|
if len(tiers) == 0 {
|
||||||
|
tiers = policy.DefaultMiningProfile(req.WalletAddress).Tiers
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := &types.MiningProfile{
|
||||||
|
ID: profileID,
|
||||||
|
Name: name,
|
||||||
|
WalletAddress: req.WalletAddress,
|
||||||
|
Tiers: tiers,
|
||||||
|
PolicyFromServer: true,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
if err := h.Store.SaveMiningProfile(ctx, profile); err != nil {
|
||||||
|
http.Error(w, "save profile failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Store.AssignMiningProfile(ctx, hostID, profileID); err != nil {
|
||||||
|
http.Error(w, "assign profile failed", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.Hub.DispatchCommand(hostID, "mining_profile", map[string]any{"profile": profile})
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"host_id": hostID,
|
||||||
|
"profile": profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostAction is an alias for fleet command dispatch (plan parity).
|
||||||
|
func (h *FleetHandler) HostAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.Command(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalibrateProfiles returns default mining tier profiles.
|
||||||
|
func CalibrateProfiles(cfg *config.Config) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"profiles": []map[string]any{
|
||||||
|
{
|
||||||
|
"id": "default-xmr",
|
||||||
|
"name": "Default XMR Chain",
|
||||||
|
"wallet_address": cfg.WalletPolicy.DefaultWallet,
|
||||||
|
"policy_from_server": true,
|
||||||
|
"tiers": []map[string]any{
|
||||||
|
{"type": "oci", "duration_minutes": 5},
|
||||||
|
{"type": "xmrig", "duration_minutes": 15},
|
||||||
|
{"type": "gpu", "duration_minutes": 10},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeerAPIStream serves GET /api/v1/seer (plan parity alias).
|
||||||
|
func SeerAPIStream(store *fleet.Store, username, password string) http.HandlerFunc {
|
||||||
|
h := &SeerHandler{Store: store, Username: username, Password: password}
|
||||||
|
return h.Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedPolicySnapshot inserts a test policy snapshot token.
|
||||||
|
func SeedPolicySnapshot(ctx context.Context, db *sql.DB, token, policyJSON string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT OR REPLACE INTO policy_snapshots (token, policy_json, expires_at)
|
||||||
|
VALUES (?, ?, datetime('now', '+1 day'))`, token, policyJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedCampaign inserts a war-room campaign row.
|
||||||
|
func SeedCampaign(ctx context.Context, db *sql.DB, code, name string, heat int) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT OR REPLACE INTO campaigns (id, code, name, heat)
|
||||||
|
VALUES (?, ?, ?, ?)`, uuid.NewString(), code, name, heat)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicBuildsList lists public build metadata.
|
||||||
|
func PublicBuildsList(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, os, arch, version, checksum, public
|
||||||
|
FROM builds WHERE public = 1 ORDER BY created_at DESC LIMIT 20`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
Download string `json:"download_url"`
|
||||||
|
}
|
||||||
|
var builds []row
|
||||||
|
for rows.Next() {
|
||||||
|
var b row
|
||||||
|
var pub int
|
||||||
|
if err := rows.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &pub); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.Download = "/api/v1/public/download/" + b.ID
|
||||||
|
builds = append(builds, b)
|
||||||
|
}
|
||||||
|
if builds == nil {
|
||||||
|
builds = []row{}
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{"builds": builds})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrucibleLegacy wraps CrucibleHandler for plan route names.
|
||||||
|
type CrucibleLegacy struct {
|
||||||
|
*CrucibleHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleLegacy) Batch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.Dispatch(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleLegacy) BatchGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
id = strings.TrimPrefix(r.URL.Path, "/api/v1/crucible/batch/")
|
||||||
|
}
|
||||||
|
if id == "" || strings.Contains(id, "/") {
|
||||||
|
http.Error(w, "batch id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, ok := c.Crucible.Get(id)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleLegacy) Exec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := fleet.CheckAction(c.OperatorClearance, fleet.ActionShell); err != nil {
|
||||||
|
auth.JSON(w, http.StatusForbidden, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.HostID == "" || req.Command == "" {
|
||||||
|
http.Error(w, "host_id and command required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd, err := c.Hub.DispatchCommand(req.HostID, fleet.ActionShell, map[string]any{"command": req.Command})
|
||||||
|
sent := err == nil
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": sent, "host_id": req.HostID, "command": req.Command, "dispatch": cmd,
|
||||||
|
})
|
||||||
|
}
|
||||||
101
internal/api/handlers/fleet.go
Normal file
101
internal/api/handlers/fleet.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge-mesh/internal/alerts"
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FleetHandler serves fleet REST endpoints.
|
||||||
|
type FleetHandler struct {
|
||||||
|
Store *fleet.Store
|
||||||
|
Hub *fleet.Hub
|
||||||
|
Alerts *alerts.Notifier
|
||||||
|
Cfg *config.Config
|
||||||
|
OperatorClearance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FleetHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
summary, err := h.Store.BuildFleetSummary()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FleetHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req registerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Hostname == "" {
|
||||||
|
req.Hostname = "enrolled-host"
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := h.Store.TouchHost(req.Hostname, req.Fingerprint, req.Arch)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "register failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"host_id": host.ID,
|
||||||
|
"host": fleet.ToFleetCard(host),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FleetHandler) Command(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("id")
|
||||||
|
if hostID == "" {
|
||||||
|
http.Error(w, "host id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Args map[string]any `json:"args"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.ToLower(req.Action)
|
||||||
|
if err := fleet.CheckAction(h.OperatorClearance, action); err != nil {
|
||||||
|
auth.JSON(w, http.StatusForbidden, map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"required_clearance": fleet.RequiredClearanceOrZero(action),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := h.Hub.DispatchCommand(hostID, action, req.Args)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "dispatch failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.Alerts != nil && h.Alerts.Enabled() {
|
||||||
|
h.Alerts.Send(alerts.FleetEvent(action, hostID, fmt.Sprintf("cmd: `%s`", cmd.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"command": cmd,
|
||||||
|
})
|
||||||
|
}
|
||||||
84
internal/api/handlers/forge.go
Normal file
84
internal/api/handlers/forge.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForgeHandler manages build artifacts.
|
||||||
|
type ForgeHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Pipeline *forge.Pipeline
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ForgeHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.DB.Query(`
|
||||||
|
SELECT id, os, arch, version, checksum, signature, public, created_at
|
||||||
|
FROM builds ORDER BY created_at DESC LIMIT 50
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type buildRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var builds []buildRow
|
||||||
|
for rows.Next() {
|
||||||
|
var b buildRow
|
||||||
|
var sig sql.NullString
|
||||||
|
var pub int
|
||||||
|
if err := rows.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &sig, &pub, &b.CreatedAt); err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.Public = pub == 1
|
||||||
|
if sig.Valid {
|
||||||
|
b.Signature = sig.String
|
||||||
|
}
|
||||||
|
builds = append(builds, b)
|
||||||
|
}
|
||||||
|
if builds == nil {
|
||||||
|
builds = []buildRow{}
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{"builds": builds})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ForgeHandler) TriggerBuild(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Public bool `json:"public"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if !req.Public {
|
||||||
|
req.Public = true
|
||||||
|
}
|
||||||
|
|
||||||
|
builds, err := h.Pipeline.BuildAll(req.Public)
|
||||||
|
if err != nil {
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"builds": builds,
|
||||||
|
})
|
||||||
|
}
|
||||||
34
internal/api/handlers/health.go
Normal file
34
internal/api/handlers/health.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health returns a basic liveness handler for GET /api/v1/health.
|
||||||
|
func Health(version string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := HealthResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Service: "forge-mesh-server",
|
||||||
|
Version: version,
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/api/handlers/install_tmpl_test.go
Normal file
29
internal/api/handlers/install_tmpl_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstallTemplateExecute(t *testing.T) {
|
||||||
|
root, _ := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||||
|
tmplPath := filepath.Join(root, "scripts", "install.sh.tpl")
|
||||||
|
b, err := os.ReadFile(tmplPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("install.sh").Parse(string(b))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
data := installData{
|
||||||
|
DeckURL: "http://localhost:8989", PublicKey: "abc", FleetSecret: "sec",
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
t.Fatalf("execute: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
253
internal/api/handlers/intelligence.go
Normal file
253
internal/api/handlers/intelligence.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/court"
|
||||||
|
"forge-mesh/internal/erasure"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntelligenceDeps bundles triple-onion fleet intelligence handlers.
|
||||||
|
type IntelligenceDeps struct {
|
||||||
|
Store *fleet.Store
|
||||||
|
Subnet *fleet.SubnetMapper
|
||||||
|
Earn *fleet.EarnGate
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunLOTL handles POST /api/v1/fleet/{id}/lotl/run — execute tier chain with recon.
|
||||||
|
func RunLOTL(deps IntelligenceDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("id")
|
||||||
|
if hostID == "" {
|
||||||
|
http.Error(w, "host id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy := fleet.AdaptiveStrategy{Store: deps.Store}
|
||||||
|
order, err := strategy.OrderForHost(r.Context(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := fleet.RunTierChain(r.Context(), deps.Store, hostID, order)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"host_id": hostID,
|
||||||
|
"order": order,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLOTL handles GET /api/v1/fleet/{id}/lotl.
|
||||||
|
func ListLOTL(deps IntelligenceDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("id")
|
||||||
|
attempts, err := deps.Store.ListLOTL(r.Context(), hostID, 100)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"host_id": hostID,
|
||||||
|
"attempts": attempts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpreadGate handles GET /api/v1/fleet/{id}/spread-gate (earn-before-burn).
|
||||||
|
func SpreadGate(deps IntelligenceDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("id")
|
||||||
|
dec, err := deps.Earn.CanSpreadToSiblings(r.Context(), hostID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubnetList handles GET /api/v1/fleet/subnets.
|
||||||
|
func SubnetList(deps IntelligenceDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cidrs, err := deps.Subnet.ListCIDRs(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]interface{}{"cidrs": cidrs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubnetAdd handles POST /api/v1/fleet/subnets.
|
||||||
|
func SubnetAdd(deps IntelligenceDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.CIDR == "" {
|
||||||
|
http.Error(w, "cidr required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := deps.Subnet.AddCIDR(r.Context(), req.CIDR)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubnetSweep handles POST /api/v1/fleet/subnets/sweep.
|
||||||
|
func SubnetSweep(deps IntelligenceDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
results, err := deps.Subnet.SweepAll(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CourtDeps bundles court handler dependencies.
|
||||||
|
type CourtDeps struct {
|
||||||
|
Court *court.Court
|
||||||
|
Seer *court.SeerHub
|
||||||
|
}
|
||||||
|
|
||||||
|
// CourtOpen handles POST /api/v1/court/sessions.
|
||||||
|
func CourtOpen(deps CourtDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.HostID == "" {
|
||||||
|
http.Error(w, "host_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := deps.Court.OpenSession(r.Context(), req.HostID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusCreated, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CourtDeliberate handles POST /api/v1/court/sessions/{id}/deliberate.
|
||||||
|
func CourtDeliberate(deps CourtDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
s, err := deps.Court.Deliberate(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CourtVerdict handles POST /api/v1/court/sessions/{id}/verdict (L4).
|
||||||
|
func CourtVerdict(deps CourtDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var req struct {
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
Clearance int `json:"clearance"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Verdict == "" {
|
||||||
|
http.Error(w, "verdict required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Clearance == 0 {
|
||||||
|
req.Clearance = 4
|
||||||
|
}
|
||||||
|
if err := deps.Court.DispatchVerdict(r.Context(), id, req.Verdict, req.Clearance); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]string{"ok": "true", "verdict": req.Verdict})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline handles GET /api/v1/fleet/{id}/timeline (LOTL + court).
|
||||||
|
func Timeline(deps CourtDeps) http.HandlerFunc {
|
||||||
|
return court.TimelineHandler(deps.Court)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErasureDeps bundles public erasure routes.
|
||||||
|
type ErasureDeps struct {
|
||||||
|
Service *erasure.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErasureEncode handles POST /api/v1/public/erasure/encode (dev/admin via basic elsewhere).
|
||||||
|
func ErasureEncode(deps ErasureDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
if name == "" {
|
||||||
|
name = "bundle"
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(r.Body, 16<<20))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read error", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := deps.Service.Encode(r.Context(), name, data)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusCreated, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErasureShard handles GET /api/v1/public/erasure/{bundle_id}/shard/{index}.
|
||||||
|
func ErasureShard(deps ErasureDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bundleID := r.PathValue("bundle_id")
|
||||||
|
idx, err := strconv.Atoi(r.PathValue("index"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid index", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sh, err := deps.Service.GetShard(r.Context(), bundleID, idx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("X-Shard-Index", strconv.Itoa(sh.ShardIndex))
|
||||||
|
_, _ = w.Write(sh.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErasureBundleMeta handles GET /api/v1/public/erasure/{bundle_id}.
|
||||||
|
func ErasureBundleMeta(deps ErasureDeps) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bundleID := r.PathValue("bundle_id")
|
||||||
|
b, err := deps.Service.GetBundle(r.Context(), bundleID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indices, _ := deps.Service.ListShards(r.Context(), bundleID)
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"bundle": b,
|
||||||
|
"shards": indices,
|
||||||
|
"public": true,
|
||||||
|
"scheme": "RS_4_2",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
138
internal/api/handlers/public.go
Normal file
138
internal/api/handlers/public.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge-mesh/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicHandlers serves unauthenticated summon routes.
|
||||||
|
type PublicHandlers struct {
|
||||||
|
DB *sql.DB
|
||||||
|
ArtifactsDir string
|
||||||
|
PublicKeyHex string
|
||||||
|
FleetSecret string
|
||||||
|
Version string
|
||||||
|
InstallTmpl *template.Template
|
||||||
|
DeckURL func(r *http.Request) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type installData struct {
|
||||||
|
DeckURL string
|
||||||
|
PublicKey string
|
||||||
|
FleetSecret string
|
||||||
|
Pin string
|
||||||
|
Campaign string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicHandlers(db *sql.DB, artifactsDir, publicKeyHex string, installTmplPath string) (*PublicHandlers, error) {
|
||||||
|
tmplBytes, err := os.ReadFile(installTmplPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read install template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("install.sh").Parse(string(tmplBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse install template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PublicHandlers{
|
||||||
|
DB: db,
|
||||||
|
ArtifactsDir: artifactsDir,
|
||||||
|
PublicKeyHex: publicKeyHex,
|
||||||
|
InstallTmpl: tmpl,
|
||||||
|
DeckURL: func(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandlers) InstallSh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := installData{
|
||||||
|
DeckURL: h.DeckURL(r),
|
||||||
|
PublicKey: h.PublicKeyHex,
|
||||||
|
FleetSecret: h.FleetSecret,
|
||||||
|
Pin: r.URL.Query().Get("pin"),
|
||||||
|
Campaign: r.URL.Query().Get("c"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := h.InstallTmpl.Execute(&buf, data); err != nil {
|
||||||
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandlers) GetRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
target := "/install.sh"
|
||||||
|
if q := r.URL.RawQuery; q != "" {
|
||||||
|
target += "?" + q
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandlers) LatestBuild(w http.ResponseWriter, r *http.Request) {
|
||||||
|
osName := r.URL.Query().Get("os")
|
||||||
|
arch := r.URL.Query().Get("arch")
|
||||||
|
if osName == "" {
|
||||||
|
osName = "linux"
|
||||||
|
}
|
||||||
|
if arch == "" {
|
||||||
|
arch = "amd64"
|
||||||
|
}
|
||||||
|
|
||||||
|
build, err := forge.LatestPublic(h.DB, osName, arch)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "no public build", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprintf(w, `{"id":%q,"os":%q,"arch":%q,"version":%q,"checksum":%q,"signature":%q,"download_url":"/api/v1/public/download/%s"}`,
|
||||||
|
build.ID, build.OS, build.Arch, build.Version, build.Checksum, build.Signature, build.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicHandlers) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if strings.Contains(id, "..") {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
build, err := forge.GetBuild(h.DB, id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !build.Public {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if build.Path == "" {
|
||||||
|
http.Error(w, "artifact missing", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="forge-mesh-agent-%s-%s"`, build.OS, build.Arch))
|
||||||
|
if err := forge.CopyArtifact(build.Path, w); err != nil {
|
||||||
|
http.Error(w, "read error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
174
internal/api/handlers/seer.go
Normal file
174
internal/api/handlers/seer.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeerHandler streams court/LOTL events via SSE.
|
||||||
|
type SeerHandler struct {
|
||||||
|
Store *fleet.Store
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SeerHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.authenticated(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
events, _ := h.Store.ListSeerEvents(20)
|
||||||
|
for _, ev := range events {
|
||||||
|
payload, _ := json.Marshal(ev)
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
events, err := h.Store.ListSeerEvents(5)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ev := range events {
|
||||||
|
payload, _ := json.Marshal(ev)
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SeerHandler) authenticated(r *http.Request) bool {
|
||||||
|
if user, pass, ok := r.BasicAuth(); ok {
|
||||||
|
return user == h.Username && pass == h.Password
|
||||||
|
}
|
||||||
|
if authHeader := r.URL.Query().Get("authorization"); authHeader != "" {
|
||||||
|
raw := strings.TrimPrefix(authHeader, "Basic ")
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(decoded), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parts[0] == h.Username && parts[1] == h.Password
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSTicketHandler issues one-time WebSocket tickets.
|
||||||
|
type WSTicketHandler struct {
|
||||||
|
Tickets *auth.TicketStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSTicketHandler) Issue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticket, err := h.Tickets.Issue()
|
||||||
|
if err != nil || ticket == "" {
|
||||||
|
http.Error(w, "ticket issue failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]string{"ticket": ticket})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorHandler returns operator clearance info.
|
||||||
|
type OperatorHandler struct {
|
||||||
|
Clearance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OperatorHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.JSON(w, http.StatusOK, map[string]any{
|
||||||
|
"clearance_level": h.Clearance,
|
||||||
|
"label": fleet.ClearanceLabel(h.Clearance),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyHandler serves wallet and mining profile endpoints.
|
||||||
|
type PolicyHandler struct {
|
||||||
|
Cfg *config.Config
|
||||||
|
Store *fleet.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PolicyHandler) GetWallet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.JSON(w, http.StatusOK, h.Cfg.WalletPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PolicyHandler) PutWallet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var wp config.WalletPolicy
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&wp); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wp.DefaultWallet != "" {
|
||||||
|
h.Cfg.WalletPolicy.DefaultWallet = wp.DefaultWallet
|
||||||
|
}
|
||||||
|
if wp.Currency != "" {
|
||||||
|
h.Cfg.WalletPolicy.Currency = wp.Currency
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, h.Cfg.WalletPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PolicyHandler) GetMiningProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.JSON(w, http.StatusOK, defaultMiningProfile(h.Cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PolicyHandler) PutMiningProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var profile miningProfileRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&profile); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if profile.WalletAddress != "" {
|
||||||
|
h.Cfg.WalletPolicy.DefaultWallet = profile.WalletAddress
|
||||||
|
}
|
||||||
|
auth.JSON(w, http.StatusOK, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
type miningProfileRequest struct {
|
||||||
|
WalletAddress string `json:"wallet_address"`
|
||||||
|
Tiers []tierEntry `json:"tiers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierEntry struct {
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultMiningProfile(cfg *config.Config) miningProfileRequest {
|
||||||
|
return miningProfileRequest{
|
||||||
|
WalletAddress: cfg.WalletPolicy.DefaultWallet,
|
||||||
|
Tiers: []tierEntry{
|
||||||
|
{Tier: 1, Name: "OCI podman", Enabled: true},
|
||||||
|
{Tier: 2, Name: "Bundled xmrig", Enabled: true},
|
||||||
|
{Tier: 3, Name: "GPU lolMiner", Enabled: true},
|
||||||
|
{Tier: 4, Name: "Stratum-direct fallback", Enabled: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
718
internal/api/integration_test.go
Normal file
718
internal/api/integration_test.go
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/handlers"
|
||||||
|
"forge-mesh/internal/erasure"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
"forge-mesh/internal/testutil"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type routeResult struct {
|
||||||
|
name string
|
||||||
|
pass bool
|
||||||
|
detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationAllRoutes(t *testing.T) {
|
||||||
|
ts := testutil.NewTestServer(t)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
seedData(t, ts)
|
||||||
|
|
||||||
|
var results []routeResult
|
||||||
|
record := func(name string, pass bool, detail string) {
|
||||||
|
results = append(results, routeResult{name: name, pass: pass, detail: detail})
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("%s: %s", name, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check := func(name string, fn func() (bool, string)) {
|
||||||
|
pass, detail := fn()
|
||||||
|
record(name, pass, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public routes ---
|
||||||
|
check("GET /api/v1/health", func() (bool, string) { return testHealth(t, ts) })
|
||||||
|
check("GET /install.sh", func() (bool, string) { return testInstallSh(t, ts) })
|
||||||
|
check("GET /get", func() (bool, string) { return testGetRedirect(t, ts) })
|
||||||
|
check("GET /spread/", func() (bool, string) { return testSpread(t, ts) })
|
||||||
|
check("GET /api/v1/public/builds", func() (bool, string) { return testPublicBuilds(t, ts) })
|
||||||
|
check("GET /api/v1/public/builds/latest", func() (bool, string) { return testPublicBuildsLatest(t, ts) })
|
||||||
|
check("GET /api/v1/public/download/{id}", func() (bool, string) { return testPublicDownload(t, ts) })
|
||||||
|
check("GET /api/v1/public/erasure/{bundle_id}", func() (bool, string) { return testErasureBundle(t, ts) })
|
||||||
|
check("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", func() (bool, string) { return testErasureShard(t, ts) })
|
||||||
|
check("GET /api/v1/public/policy-snapshot/{token}", func() (bool, string) { return testPolicySnapshot(t, ts) })
|
||||||
|
|
||||||
|
// --- Agent routes (Bearer) ---
|
||||||
|
check("POST /api/v1/fleet/register", func() (bool, string) { return testFleetRegister(t, ts) })
|
||||||
|
check("POST /api/v1/fleet/beacon", func() (bool, string) { return testFleetBeacon(t, ts) })
|
||||||
|
|
||||||
|
// --- Protected routes (Basic) ---
|
||||||
|
check("GET /api/v1/fleet", func() (bool, string) { return testFleetList(t, ts) })
|
||||||
|
check("GET /api/v1/fleet/hosts", func() (bool, string) { return testFleetHosts(t, ts) })
|
||||||
|
check("POST /api/v1/fleet/{id}/mining-profile", func() (bool, string) { return testMiningProfile(t, ts) })
|
||||||
|
check("POST /api/v1/fleet/{id}/action pause", func() (bool, string) { return testFleetAction(t, ts, "pause") })
|
||||||
|
check("POST /api/v1/fleet/{id}/action reboot", func() (bool, string) { return testFleetAction(t, ts, "reboot") })
|
||||||
|
check("POST /api/v1/fleet/{id}/action screenshot", func() (bool, string) { return testFleetAction(t, ts, "screenshot") })
|
||||||
|
check("GET /api/v1/fleet/{id}/lotl/timeline", func() (bool, string) { return testLOTLTimeline(t, ts) })
|
||||||
|
check("GET /api/v1/forge/builds", func() (bool, string) { return testForgeBuilds(t, ts) })
|
||||||
|
check("GET /api/v1/calibrate/profiles", func() (bool, string) { return testCalibrateProfiles(t, ts) })
|
||||||
|
check("POST /api/v1/crucible/batch", func() (bool, string) { return testCrucibleBatch(t, ts) })
|
||||||
|
check("GET /api/v1/crucible/batch/{id}", func() (bool, string) { return testCrucibleBatchGet(t, ts) })
|
||||||
|
check("POST /api/v1/crucible/exec", func() (bool, string) { return testCrucibleExec(t, ts) })
|
||||||
|
check("GET /api/v1/seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/api/v1/seer") })
|
||||||
|
check("GET /seer (SSE)", func() (bool, string) { return testSeerSSE(t, ts, "/seer") })
|
||||||
|
check("GET /api/v1/war-room/campaigns", func() (bool, string) { return testWarRoom(t, ts) })
|
||||||
|
check("GET /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardList(t, ts) })
|
||||||
|
check("POST /api/v1/wireguard/peers", func() (bool, string) { return testWireGuardCreate(t, ts) })
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
check("POST /api/v1/ws/ticket", func() (bool, string) { return testWSTicket(t, ts) })
|
||||||
|
check("GET /api/v1/ws/fleet deck connect", func() (bool, string) { return testWSFleetDeck(t, ts) })
|
||||||
|
check("GET /api/v1/ws/fleet agent heartbeat", func() (bool, string) { return testWSFleetAgentHeartbeat(t, ts) })
|
||||||
|
check("GET /api/v1/ws/fleet command roundtrip", func() (bool, string) { return testWSCommandRoundtrip(t, ts) })
|
||||||
|
|
||||||
|
t.Log("--- Route checklist ---")
|
||||||
|
passed, failed := 0, 0
|
||||||
|
for _, r := range results {
|
||||||
|
status := "PASS"
|
||||||
|
if !r.pass {
|
||||||
|
status = "FAIL"
|
||||||
|
failed++
|
||||||
|
} else {
|
||||||
|
passed++
|
||||||
|
}
|
||||||
|
t.Logf("[%s] %s %s", status, r.name, r.detail)
|
||||||
|
}
|
||||||
|
t.Logf("Total: %d passed, %d failed", passed, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
testHostID string
|
||||||
|
testBundleID string
|
||||||
|
testBatchID string
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedData(t *testing.T, ts *testutil.TestServer) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Demo host from SeedDemoHost
|
||||||
|
hosts, err := fleet.NewStore(ts.SQL).ListHosts()
|
||||||
|
if err != nil || len(hosts) == 0 {
|
||||||
|
t.Fatal("expected seeded demo host")
|
||||||
|
}
|
||||||
|
testHostID = hosts[0].ID
|
||||||
|
|
||||||
|
_ = fleet.NewStore(ts.SQL).LogLOTL(ctx, testHostID, 2, "deploy", "success", "", `{}`)
|
||||||
|
|
||||||
|
svc := erasure.NewService(ts.SQL)
|
||||||
|
bundle, err := svc.Encode(ctx, "integration-test", []byte("aetherforge erasure integration payload"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed erasure: %v", err)
|
||||||
|
}
|
||||||
|
testBundleID = bundle.ID
|
||||||
|
|
||||||
|
policyJSON := `{"wallet_address":"test-wallet","tiers":[{"type":"xmrig"}]}`
|
||||||
|
if err := handlers.SeedPolicySnapshot(ctx, ts.SQL, "test-policy-token", policyJSON); err != nil {
|
||||||
|
t.Fatalf("seed policy: %v", err)
|
||||||
|
}
|
||||||
|
if err := handlers.SeedCampaign(ctx, ts.SQL, "ember", "Emberwake Test", 42); err != nil {
|
||||||
|
t.Fatalf("seed campaign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactPath := filepath.Join(t.TempDir(), "agent-linux-amd64")
|
||||||
|
if err := os.WriteFile(artifactPath, []byte("#!/bin/sh\necho agent"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write artifact: %v", err)
|
||||||
|
}
|
||||||
|
buildID := "integration-build-amd64"
|
||||||
|
_, err = ts.SQL.ExecContext(ctx, `
|
||||||
|
INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at)
|
||||||
|
VALUES (?, 'linux', 'amd64', 'integration-test', 'abc123', 'sig', 1, ?, datetime('now'))`,
|
||||||
|
buildID, artifactPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed build: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHealth(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/health")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
if body["status"] != "ok" {
|
||||||
|
return false, fmt.Sprintf("body %v", body)
|
||||||
|
}
|
||||||
|
return true, "200 ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInstallSh(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/install.sh")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return false, fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return true, "200 shell script"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetRedirect(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/get?c=test")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusFound {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
loc := resp.Header.Get("Location")
|
||||||
|
if !strings.Contains(loc, "/install.sh") {
|
||||||
|
return false, "missing install.sh redirect"
|
||||||
|
}
|
||||||
|
return true, "302 -> install.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpread(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/spread/?c=ember")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "Emberwake") {
|
||||||
|
return false, "missing lander content"
|
||||||
|
}
|
||||||
|
return true, "200 HTML lander"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPublicBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/public/builds")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 builds list"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPublicBuildsLatest(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/public/builds/latest?os=linux&arch=amd64")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 latest build"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPublicDownload(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/public/download/integration-build-amd64")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 artifact stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testErasureBundle(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 bundle metadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testErasureShard(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/public/erasure/" + testBundleID + "/shard/0")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 shard hex"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolicySnapshot(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/public/policy-snapshot/test-policy-token")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 policy JSON"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFleetRegister(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
body := bytes.NewBufferString(`{"hostname":"agent-integration","arch":"amd64"}`)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/register", body)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ts.FleetSecret)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 enrolled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFleetBeacon(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
payload := fmt.Sprintf(`{"host_id":%q,"hostname":"beacon-host","hashrate_hps":1000}`, testHostID)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/fleet/beacon", strings.NewReader(payload))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ts.FleetSecret)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 beacon ack"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFleetList(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/fleet")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 fleet summary"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFleetHosts(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/fleet/hosts")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
if _, ok := body["hosts"]; !ok {
|
||||||
|
return false, "missing hosts key"
|
||||||
|
}
|
||||||
|
return true, "200 hosts list"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMiningProfile(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
body := bytes.NewBufferString(`{"wallet_address":"4integrationtestwallet","name":"Integration"}`)
|
||||||
|
resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/mining-profile", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 profile pushed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFleetAction(t *testing.T, ts *testutil.TestServer, action string) (bool, string) {
|
||||||
|
body := bytes.NewBufferString(fmt.Sprintf(`{"action":%q}`, action))
|
||||||
|
resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 " + action + " dispatched"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLOTLTimeline(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/fleet/"+testHostID+"/lotl/timeline")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
if _, ok := body["timeline"]; !ok {
|
||||||
|
return false, "missing timeline"
|
||||||
|
}
|
||||||
|
return true, "200 LOTL timeline"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForgeBuilds(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/forge/builds")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 forge builds"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalibrateProfiles(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/calibrate/profiles")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 calibrate profiles"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrucibleBatch(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
body := bytes.NewBufferString(fmt.Sprintf(`{"command":"status","host_ids":[%q]}`, testHostID))
|
||||||
|
resp, err := basicPost(ts, "/api/v1/crucible/batch", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var job map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&job); err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
id, _ := job["id"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return false, "missing job id"
|
||||||
|
}
|
||||||
|
testBatchID = id
|
||||||
|
return true, "200 batch created"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrucibleBatchGet(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
if testBatchID == "" {
|
||||||
|
return false, "no batch id from prior test"
|
||||||
|
}
|
||||||
|
resp, err := basicGet(ts, "/api/v1/crucible/batch/"+testBatchID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 batch status"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrucibleExec(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
body := bytes.NewBufferString(fmt.Sprintf(`{"host_id":%q,"command":"shell echo hi"}`, testHostID))
|
||||||
|
resp, err := basicPost(ts, "/api/v1/crucible/exec", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 exec dispatched"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSeerSSE(t *testing.T, ts *testutil.TestServer, path string) (bool, string) {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||||
|
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
if !strings.Contains(ct, "text/event-stream") {
|
||||||
|
return false, "not SSE: " + ct
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
return false, "no SSE data line"
|
||||||
|
}
|
||||||
|
return true, "200 SSE stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWarRoom(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/war-room/campaigns")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
campaigns, _ := body["campaigns"].([]any)
|
||||||
|
if len(campaigns) == 0 {
|
||||||
|
return false, "empty campaigns"
|
||||||
|
}
|
||||||
|
return true, "200 war room campaigns"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWireGuardList(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
resp, err := basicGet(ts, "/api/v1/wireguard/peers")
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "200 peers list"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWireGuardCreate(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
body := bytes.NewBufferString(`{"host_id":"` + testHostID + `","public_key":"wg-test-pubkey","endpoint":"10.0.0.1:51820"}`)
|
||||||
|
resp, err := basicPost(ts, "/api/v1/wireguard/peers", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return true, "201 peer created"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWSTicket(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil)
|
||||||
|
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" {
|
||||||
|
return false, "empty ticket"
|
||||||
|
}
|
||||||
|
return true, "200 ticket issued"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWSFleetDeck(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
ticket := issueTicket(t, ts)
|
||||||
|
wsURL := wsURL(ts.URL, "/api/v1/ws/fleet?ticket="+url.QueryEscape(ticket))
|
||||||
|
|
||||||
|
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_ = conn.WriteMessage(websocket.PingMessage, nil)
|
||||||
|
return true, "101 deck connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWSFleetAgentHeartbeat(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
conn, resp, err := dialAgentWS(ts)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||||
|
return false, fmt.Sprintf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"ws-agent","hashrate_hps":5000}`, testHostID)
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||||
|
_, _, err = conn.ReadMessage()
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "timeout") {
|
||||||
|
// heartbeat may not produce a direct reply; connection staying open is success
|
||||||
|
}
|
||||||
|
return true, "101 agent heartbeat sent"
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWSCommandRoundtrip(t *testing.T, ts *testutil.TestServer) (bool, string) {
|
||||||
|
conn, _, err := dialAgentWS(ts)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cmdCh := make(chan []byte, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, msg, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var frame map[string]any
|
||||||
|
if json.Unmarshal(msg, &frame) == nil && frame["type"] == "command" {
|
||||||
|
cmdCh <- msg
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
hb := fmt.Sprintf(`{"type":"heartbeat","host_id":%q,"hostname":"cmd-agent","hashrate_hps":9000}`, testHostID)
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(hb)); err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
body := bytes.NewBufferString(`{"action":"pause"}`)
|
||||||
|
resp, err := basicPost(ts, "/api/v1/fleet/"+testHostID+"/action", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err.Error()
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Sprintf("action status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-cmdCh:
|
||||||
|
var frame map[string]any
|
||||||
|
_ = json.Unmarshal(msg, &frame)
|
||||||
|
return true, fmt.Sprintf("command action=%v", frame["command"])
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
return false, "no command frame received"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialAgentWS(ts *testutil.TestServer) (*websocket.Conn, *http.Response, error) {
|
||||||
|
wsURL := wsURL(ts.URL, "/api/v1/ws/fleet")
|
||||||
|
header := http.Header{}
|
||||||
|
header.Set("Authorization", "Bearer "+ts.FleetSecret)
|
||||||
|
return websocket.DefaultDialer.Dial(wsURL, header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueTicket(t *testing.T, ts *testutil.TestServer) string {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil)
|
||||||
|
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out struct {
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" {
|
||||||
|
t.Fatal("ticket issue failed")
|
||||||
|
}
|
||||||
|
return out.Ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
func wsURL(httpURL, path string) string {
|
||||||
|
u, _ := url.Parse(httpURL)
|
||||||
|
u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1)
|
||||||
|
u.Path = ""
|
||||||
|
u.RawPath = ""
|
||||||
|
u.RawQuery = ""
|
||||||
|
if strings.Contains(path, "?") {
|
||||||
|
parts := strings.SplitN(path, "?", 2)
|
||||||
|
u.Path = parts[0]
|
||||||
|
u.RawQuery = parts[1]
|
||||||
|
} else {
|
||||||
|
u.Path = path
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicGet(ts *testutil.TestServer, path string) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicPost(ts *testutil.TestServer, path string, body io.Reader) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, ts.URL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(ts.BasicUser, ts.BasicPass)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure unused import guard for sql in case of build tags
|
||||||
|
var _ = sql.ErrNoRows
|
||||||
186
internal/api/router_test.go
Normal file
186
internal/api/router_test.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/db"
|
||||||
|
"forge-mesh/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthAndPublicRoutes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
writeTestConfig(t, cfgPath, dir)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cfg.EnsureDataDirs(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := db.Open(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl")
|
||||||
|
static := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
||||||
|
|
||||||
|
srv, err := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("health: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = http.Get(ts.URL + "/install.sh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("install.sh: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = http.Get(ts.URL + "/get")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("get redirect: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtectedFleetRequiresAuth(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
writeTestConfig(t, cfgPath, dir)
|
||||||
|
|
||||||
|
cfg, _ := config.Load(cfgPath)
|
||||||
|
_ = cfg.EnsureDataDirs()
|
||||||
|
conn, _ := db.Open(cfg.DatabasePath)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
kp, _ := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||||
|
tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl")
|
||||||
|
static := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}}
|
||||||
|
|
||||||
|
srv, err := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/fleet/hosts")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/fleet/hosts", nil)
|
||||||
|
req.SetBasicAuth("admin", "changeme")
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
if _, ok := body["hosts"]; !ok {
|
||||||
|
t.Fatalf("expected hosts key in %v", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWSTicketFlow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
writeTestConfig(t, cfgPath, dir)
|
||||||
|
|
||||||
|
cfg, _ := config.Load(cfgPath)
|
||||||
|
_ = cfg.EnsureDataDirs()
|
||||||
|
conn, _ := db.Open(cfg.DatabasePath)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
kp, _ := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||||
|
tmplPath := filepath.Join("..", "..", "scripts", "install.sh.tpl")
|
||||||
|
static := fs.FS(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("ok")}})
|
||||||
|
|
||||||
|
srv, _ := NewServer(cfg, conn, static, "test", tmplPath, kp.PublicKeyHex())
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/ws/ticket", nil)
|
||||||
|
req.SetBasicAuth("admin", "changeme")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ticket: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Ticket == "" {
|
||||||
|
t.Fatal("expected ticket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestConfig(t *testing.T, path, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
content := `{
|
||||||
|
"listen_addr": ":0",
|
||||||
|
"data_dir": "` + dir + `",
|
||||||
|
"database_path": "` + filepath.Join(dir, "test.db") + `",
|
||||||
|
"auth": {
|
||||||
|
"basic_username": "admin",
|
||||||
|
"basic_password": "changeme",
|
||||||
|
"fleet_secret": "test-fleet-secret"
|
||||||
|
},
|
||||||
|
"forge": {
|
||||||
|
"signing_key_path": "` + filepath.Join(dir, "signing.key") + `",
|
||||||
|
"artifacts_dir": "` + filepath.Join(dir, "artifacts") + `"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
256
internal/api/server.go
Normal file
256
internal/api/server.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/alerts"
|
||||||
|
"forge-mesh/internal/api/handlers"
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/court"
|
||||||
|
"forge-mesh/internal/erasure"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
"forge-mesh/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the forge-mesh HTTP control plane.
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
mux http.Handler
|
||||||
|
staticFS fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer wires routes, fleet hub, and static SPA handler.
|
||||||
|
func NewServer(
|
||||||
|
cfg *config.Config,
|
||||||
|
db *sql.DB,
|
||||||
|
staticFS fs.FS,
|
||||||
|
version, installTmplPath, publicKeyHex string,
|
||||||
|
) (*Server, error) {
|
||||||
|
store := fleet.NewStore(db)
|
||||||
|
_ = store.SeedDemoHost()
|
||||||
|
|
||||||
|
tickets := auth.NewTicketStore(5 * time.Minute)
|
||||||
|
hub := fleet.NewHub(store, cfg.Auth.FleetSecret, tickets)
|
||||||
|
crucible := fleet.NewCrucibleStore(100)
|
||||||
|
|
||||||
|
tgCfg := alerts.Config{
|
||||||
|
Enabled: cfg.Telegram.Enabled,
|
||||||
|
BotToken: cfg.Telegram.BotToken,
|
||||||
|
ChatID: cfg.Telegram.ChatID,
|
||||||
|
}
|
||||||
|
notifier := alerts.New(tgCfg)
|
||||||
|
|
||||||
|
public, err := handlers.NewPublicHandlers(db, cfg.Forge.ArtifactsDir, publicKeyHex, installTmplPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
public.FleetSecret = cfg.Auth.FleetSecret
|
||||||
|
public.Version = version
|
||||||
|
|
||||||
|
pipeline, err := forge.NewPipeline(db, cfg.Forge.ArtifactsDir, cfg.Forge.SigningKeyPath,
|
||||||
|
filepath.Join("cmd", "agent"), version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
clearance := cfg.OperatorClearance
|
||||||
|
fleetH := &handlers.FleetHandler{
|
||||||
|
Store: store, Hub: hub, Alerts: notifier, Cfg: cfg, OperatorClearance: clearance,
|
||||||
|
}
|
||||||
|
crucibleH := &handlers.CrucibleHandler{
|
||||||
|
Store: store, Hub: hub, Crucible: crucible, Alerts: notifier, OperatorClearance: clearance,
|
||||||
|
}
|
||||||
|
policyH := &handlers.PolicyHandler{Cfg: cfg, Store: store}
|
||||||
|
forgeH := &handlers.ForgeHandler{DB: db, Pipeline: pipeline, Version: version}
|
||||||
|
seerH := &handlers.SeerHandler{
|
||||||
|
Store: store,
|
||||||
|
Username: cfg.Auth.BasicUsername,
|
||||||
|
Password: cfg.Auth.BasicPassword,
|
||||||
|
}
|
||||||
|
authH := &handlers.AuthHandlers{Tickets: tickets}
|
||||||
|
opH := &handlers.OperatorHandler{Clearance: clearance}
|
||||||
|
dropperH := &handlers.DropperHandler{
|
||||||
|
DB: db,
|
||||||
|
PublicKeyHex: publicKeyHex,
|
||||||
|
FleetSecret: cfg.Auth.FleetSecret,
|
||||||
|
Version: version,
|
||||||
|
}
|
||||||
|
erasureH := &handlers.ErasureHandler{Service: erasure.NewService(db)}
|
||||||
|
policySnapH := &handlers.PolicySnapshotHandler{DB: db}
|
||||||
|
warRoomH := &handlers.WarRoomHandler{DB: db}
|
||||||
|
wgH := &handlers.WireGuardHandler{DB: db}
|
||||||
|
crucibleLegacy := &handlers.CrucibleLegacy{CrucibleHandler: crucibleH}
|
||||||
|
|
||||||
|
courtSvc := court.New(db, cfg.Court, store)
|
||||||
|
intelDeps := handlers.IntelligenceDeps{
|
||||||
|
Store: store,
|
||||||
|
Subnet: &fleet.SubnetMapper{Store: store},
|
||||||
|
Earn: &fleet.EarnGate{Store: store, Config: fleet.DefaultEarnConfig()},
|
||||||
|
}
|
||||||
|
courtDeps := handlers.CourtDeps{Court: courtSvc, Seer: court.NewSeerHub(db)}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Public
|
||||||
|
mux.HandleFunc("GET /api/v1/health", handlers.Health(version))
|
||||||
|
mux.HandleFunc("GET /install.sh", public.InstallSh)
|
||||||
|
mux.HandleFunc("GET /get", public.GetRedirect)
|
||||||
|
mux.HandleFunc("GET /spread/", handlers.SpreadLander(db))
|
||||||
|
mux.HandleFunc("GET /spread", handlers.SpreadLander(db))
|
||||||
|
mux.HandleFunc("GET /api/v1/public/builds", handlers.PublicBuildsList(db))
|
||||||
|
mux.HandleFunc("GET /api/v1/public/builds/latest", public.LatestBuild)
|
||||||
|
mux.HandleFunc("GET /api/v1/public/download/{id}", public.Download)
|
||||||
|
mux.HandleFunc("GET /api/v1/public/erasure/{bundle_id}", erasureH.GetBundle)
|
||||||
|
mux.HandleFunc("GET /api/v1/public/erasure/{bundle_id}/shard/{index}", erasureH.GetShard)
|
||||||
|
mux.HandleFunc("GET /api/v1/public/policy-snapshot/{token}", policySnapH.Get)
|
||||||
|
mux.HandleFunc("GET /api/v1/public/campaign/track", handlers.TrackCampaign(db))
|
||||||
|
|
||||||
|
// Agent (fleet secret)
|
||||||
|
mux.Handle("POST /api/v1/beacon", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret,
|
||||||
|
http.HandlerFunc(hub.HandleBeacon(store))))
|
||||||
|
mux.Handle("POST /api/v1/fleet/beacon", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret,
|
||||||
|
http.HandlerFunc(hub.HandleBeacon(store))))
|
||||||
|
mux.Handle("POST /api/v1/fleet/register", auth.FleetSecretMiddleware(cfg.Auth.FleetSecret,
|
||||||
|
http.HandlerFunc(fleetH.Register)))
|
||||||
|
mux.HandleFunc("GET /api/v1/ws/agent", hub.HandleAgentWS)
|
||||||
|
mux.HandleFunc("GET /api/v1/ws/fleet", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("ticket") != "" {
|
||||||
|
hub.HandleDeckWS(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub.HandleAgentWS(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Protected (Basic auth)
|
||||||
|
protected := http.NewServeMux()
|
||||||
|
protected.HandleFunc("GET /api/v1/fleet", fleetH.List)
|
||||||
|
protected.HandleFunc("GET /api/v1/fleet/hosts", fleetH.List)
|
||||||
|
protected.HandleFunc("GET /api/v1/fleet/{id}/lotl/timeline", fleetH.LOTLTimeline)
|
||||||
|
protected.HandleFunc("GET /api/v1/fleet/{id}/timeline", handlers.Timeline(courtDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/fleet/{id}/lotl/run", handlers.RunLOTL(intelDeps))
|
||||||
|
protected.HandleFunc("GET /api/v1/fleet/{id}/spread-gate", handlers.SpreadGate(intelDeps))
|
||||||
|
protected.HandleFunc("GET /api/v1/fleet/subnets", handlers.SubnetList(intelDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/fleet/subnets", handlers.SubnetAdd(intelDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/fleet/subnets/sweep", handlers.SubnetSweep(intelDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/court/sessions", handlers.CourtOpen(courtDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/court/sessions/{id}/deliberate", handlers.CourtDeliberate(courtDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/court/sessions/{id}/verdict", handlers.CourtVerdict(courtDeps))
|
||||||
|
protected.HandleFunc("POST /api/v1/fleet/{id}/command", fleetH.Command)
|
||||||
|
protected.HandleFunc("POST /api/v1/fleet/{id}/action", fleetH.HostAction)
|
||||||
|
protected.HandleFunc("POST /api/v1/fleet/{id}/mining-profile", fleetH.PushMiningProfile)
|
||||||
|
protected.HandleFunc("GET /api/v1/dropper", dropperH.Info)
|
||||||
|
protected.HandleFunc("GET /api/v1/operator/me", opH.Me)
|
||||||
|
protected.HandleFunc("POST /api/v1/ws/ticket", authH.WSTicket)
|
||||||
|
protected.HandleFunc("GET /api/v1/forge/builds", forgeH.ListBuilds)
|
||||||
|
protected.HandleFunc("POST /api/v1/forge/builds/trigger", forgeH.TriggerBuild)
|
||||||
|
protected.HandleFunc("GET /api/v1/policy/wallet", policyH.GetWallet)
|
||||||
|
protected.HandleFunc("PUT /api/v1/policy/wallet", policyH.PutWallet)
|
||||||
|
protected.HandleFunc("GET /api/v1/policy/mining-profile", policyH.GetMiningProfile)
|
||||||
|
protected.HandleFunc("PUT /api/v1/policy/mining-profile", policyH.PutMiningProfile)
|
||||||
|
protected.HandleFunc("POST /api/v1/policy/snapshot", policySnapH.Create)
|
||||||
|
protected.HandleFunc("GET /api/v1/calibrate/profiles", handlers.CalibrateProfiles(cfg))
|
||||||
|
protected.HandleFunc("POST /api/v1/crucible/batch", crucibleLegacy.Batch)
|
||||||
|
protected.HandleFunc("GET /api/v1/crucible/batch/{id}", crucibleLegacy.BatchGet)
|
||||||
|
protected.HandleFunc("POST /api/v1/crucible/exec", crucibleLegacy.Exec)
|
||||||
|
protected.HandleFunc("POST /api/v1/crucible/dispatch", crucibleH.Dispatch)
|
||||||
|
protected.HandleFunc("GET /api/v1/crucible/jobs/{id}", crucibleH.GetJob)
|
||||||
|
protected.HandleFunc("GET /api/v1/crucible/history", crucibleH.History)
|
||||||
|
protected.HandleFunc("GET /api/v1/seer", handlers.SeerAPIStream(store, cfg.Auth.BasicUsername, cfg.Auth.BasicPassword))
|
||||||
|
protected.HandleFunc("GET /api/v1/war-room/campaigns", warRoomH.ListCampaigns)
|
||||||
|
protected.HandleFunc("GET /api/v1/wireguard/peers", wgH.ListPeers)
|
||||||
|
protected.HandleFunc("POST /api/v1/wireguard/peers", wgH.CreatePeer)
|
||||||
|
protected.HandleFunc("GET /api/v1/wireguard/config", wgH.RenderConfig)
|
||||||
|
protected.HandleFunc("GET /seer", seerH.Stream)
|
||||||
|
|
||||||
|
authWrap := auth.BasicAuthMiddleware(cfg.Auth.BasicUsername, cfg.Auth.BasicPassword)
|
||||||
|
mux.Handle("/api/v1/", authWrap(protected))
|
||||||
|
mux.Handle("/seer", authWrap(http.HandlerFunc(seerH.Stream)))
|
||||||
|
|
||||||
|
// Static SPA (React build embedded in webroot)
|
||||||
|
if staticFS != nil {
|
||||||
|
fileServer := http.FileServer(http.FS(staticFS))
|
||||||
|
mux.Handle("/", spaFallback(staticFS, fileServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{cfg: cfg, mux: mux, staticFS: staticFS}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return s.mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func spaFallback(staticFS fs.FS, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/install.sh" || r.URL.Path == "/get" || strings.HasPrefix(r.URL.Path, "/spread") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.Stat(staticFS, path); err != nil {
|
||||||
|
// Client-side route — serve index.html
|
||||||
|
if data, err := fs.ReadFile(staticFS, "index.html"); err == nil {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncWebroot copies web/dist into cmd/server/webroot for go:embed.
|
||||||
|
func SyncWebroot(distDir, webrootDir string) error {
|
||||||
|
if err := os.RemoveAll(webrootDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return copyDir(distDir, webrootDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
return copyFile(path, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
16
internal/api/types/build.go
Normal file
16
internal/api/types/build.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Build represents a forge-produced agent artifact.
|
||||||
|
type Build struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
Public bool `json:"public"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
13
internal/api/types/campaign.go
Normal file
13
internal/api/types/campaign.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Campaign tracks Emberwake funnel tags (?c=).
|
||||||
|
type Campaign struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Pin string `json:"pin,omitempty"`
|
||||||
|
Heat int `json:"heat"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
22
internal/api/types/host.go
Normal file
22
internal/api/types/host.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Host represents an enrolled fleet member.
|
||||||
|
type Host struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Phenotype string `json:"phenotype,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Hashrate float64 `json:"hashrate"`
|
||||||
|
HashrateHps float64 `json:"hashrate_hps"`
|
||||||
|
CurrentTier int `json:"current_tier"`
|
||||||
|
TierType string `json:"tier_type,omitempty"`
|
||||||
|
TierState string `json:"tier_state,omitempty"`
|
||||||
|
ClearanceLevel int `json:"clearance_level"`
|
||||||
|
MiningProfileID *string `json:"mining_profile_id,omitempty"`
|
||||||
|
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
63
internal/api/types/message.go
Normal file
63
internal/api/types/message.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// HeartbeatPayload is sent by agents over WS or beacon.
|
||||||
|
type HeartbeatPayload struct {
|
||||||
|
HostID string `json:"host_id,omitempty"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
Hashrate float64 `json:"hashrate"`
|
||||||
|
HashrateHps float64 `json:"hashrate_hps"`
|
||||||
|
CurrentTier int `json:"current_tier"`
|
||||||
|
TierType string `json:"tier_type,omitempty"`
|
||||||
|
TierState string `json:"tier_state,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHashrateFields keeps hashrate and hashrate_hps in sync for older clients.
|
||||||
|
func (h *HeartbeatPayload) SetHashrateFields(hps float64) {
|
||||||
|
h.HashrateHps = hps
|
||||||
|
h.Hashrate = hps
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveHashrate returns hashrate_hps when set, otherwise legacy hashrate.
|
||||||
|
func (h HeartbeatPayload) EffectiveHashrate() float64 {
|
||||||
|
if h.HashrateHps > 0 {
|
||||||
|
return h.HashrateHps
|
||||||
|
}
|
||||||
|
return h.Hashrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeaconResponse is returned by HTTPS beacon fallback.
|
||||||
|
type BeaconResponse struct {
|
||||||
|
Commands []FleetCommand `json:"commands"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Profile *MiningProfile `json:"mining_profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WsMessage is the wire format for fleet WebSocket frames.
|
||||||
|
type WsMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Host *Host `json:"host,omitempty"`
|
||||||
|
HostID string `json:"host_id,omitempty"`
|
||||||
|
Command *FleetCommand `json:"command,omitempty"`
|
||||||
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FleetCommand is dispatched from deck to agent.
|
||||||
|
type FleetCommand struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Args map[string]any `json:"args,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiningProfileRequest assigns or updates a host mining profile.
|
||||||
|
type MiningProfileRequest struct {
|
||||||
|
ProfileID string `json:"profile_id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
WalletAddress string `json:"wallet_address"`
|
||||||
|
Tiers []MiningTierSpec `json:"tiers,omitempty"`
|
||||||
|
}
|
||||||
21
internal/api/types/mining_profile.go
Normal file
21
internal/api/types/mining_profile.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MiningTierSpec defines one step in a tiered miner chain.
|
||||||
|
type MiningTierSpec struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Duration int `json:"duration_minutes,omitempty"`
|
||||||
|
Config map[string]string `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiningProfile is an ordered list of mining tiers pushed to agents.
|
||||||
|
type MiningProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
WalletAddress string `json:"wallet_address"`
|
||||||
|
Tiers []MiningTierSpec `json:"tiers"`
|
||||||
|
PolicyFromServer bool `json:"policy_from_server"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
131
internal/auth/auth.go
Normal file
131
internal/auth/auth.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials for HTTP Basic and fleet bearer auth.
|
||||||
|
type Credentials struct {
|
||||||
|
BasicUsername string
|
||||||
|
BasicPassword string
|
||||||
|
FleetSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketStore issues short-lived WebSocket tickets after Basic login.
|
||||||
|
type TicketStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tickets map[string]time.Time
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTicketStore creates a ticket store with the default TTL.
|
||||||
|
func NewTicketStore(ttl ...time.Duration) *TicketStore {
|
||||||
|
d := 5 * time.Minute
|
||||||
|
if len(ttl) > 0 && ttl[0] > 0 {
|
||||||
|
d = ttl[0]
|
||||||
|
}
|
||||||
|
return &TicketStore{
|
||||||
|
tickets: make(map[string]time.Time),
|
||||||
|
ttl: d,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketStore) Issue() (string, error) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ticket := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.tickets[ticket] = time.Now().Add(s.ttl)
|
||||||
|
s.gcLocked()
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketStore) Validate(ticket string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
exp, ok := s.tickets[ticket]
|
||||||
|
if !ok || time.Now().After(exp) {
|
||||||
|
delete(s.tickets, ticket)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume validates and removes a one-time ticket.
|
||||||
|
func (s *TicketStore) Consume(ticket string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
exp, ok := s.tickets[ticket]
|
||||||
|
if !ok || time.Now().After(exp) {
|
||||||
|
delete(s.tickets, ticket)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.tickets, ticket)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketStore) gcLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for k, exp := range s.tickets {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(s.tickets, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractBearer returns the token from an Authorization Bearer header.
|
||||||
|
func ExtractBearer(r *http.Request) string {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuthMiddleware protects routes with HTTP Basic credentials.
|
||||||
|
func BasicAuthMiddleware(username, password string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok || user != username || pass != password {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="forge-mesh"`)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuthMiddlewareCreds is the struct-based variant for router wiring.
|
||||||
|
func BasicAuthMiddlewareCreds(creds Credentials) func(http.Handler) http.Handler {
|
||||||
|
return BasicAuthMiddleware(creds.BasicUsername, creds.BasicPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FleetSecretMiddleware validates agent bearer tokens.
|
||||||
|
func FleetSecretMiddleware(secret string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := ExtractBearer(r)
|
||||||
|
if token == "" || token != secret {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON writes a JSON response.
|
||||||
|
func JSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
82
internal/auth/auth_test.go
Normal file
82
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTicketStoreIssueValidate(t *testing.T) {
|
||||||
|
store := NewTicketStore(0)
|
||||||
|
ticket, err := store.Issue()
|
||||||
|
if err != nil || ticket == "" {
|
||||||
|
t.Fatal("expected valid ticket")
|
||||||
|
}
|
||||||
|
if !store.Validate(ticket) {
|
||||||
|
t.Fatal("expected valid ticket")
|
||||||
|
}
|
||||||
|
if store.Validate("invalid") {
|
||||||
|
t.Fatal("expected invalid ticket to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTicketStoreConsume(t *testing.T) {
|
||||||
|
store := NewTicketStore(0)
|
||||||
|
ticket, err := store.Issue()
|
||||||
|
if err != nil || !store.Consume(ticket) {
|
||||||
|
t.Fatal("expected consume to succeed")
|
||||||
|
}
|
||||||
|
if store.Validate(ticket) {
|
||||||
|
t.Fatal("consumed ticket should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthMiddleware(t *testing.T) {
|
||||||
|
handler := BasicAuthMiddleware("admin", "secret")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("admin", "secret")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetSecretMiddleware(t *testing.T) {
|
||||||
|
handler := FleetSecretMiddleware("fleet-secret", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/beacon", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/beacon", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer fleet-secret")
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractBearer(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer abc123")
|
||||||
|
if got := ExtractBearer(req); got != "abc123" {
|
||||||
|
t.Fatalf("expected abc123, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/config/config.go
Normal file
134
internal/config/config.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds forge-mesh server configuration loaded from config.json.
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string `json:"listen_addr"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
DatabasePath string `json:"database_path"`
|
||||||
|
OperatorClearance int `json:"operator_clearance"`
|
||||||
|
Auth AuthConfig `json:"auth"`
|
||||||
|
WalletPolicy WalletPolicy `json:"wallet_policy"`
|
||||||
|
Stratum StratumConfig `json:"stratum"`
|
||||||
|
Forge ForgeConfig `json:"forge"`
|
||||||
|
Court CourtConfig `json:"court"`
|
||||||
|
Telegram TelegramConfig `json:"telegram"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramConfig holds alert bot settings (W19).
|
||||||
|
type TelegramConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
BasicUsername string `json:"basic_username"`
|
||||||
|
BasicPassword string `json:"basic_password"`
|
||||||
|
FleetSecret string `json:"fleet_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WalletPolicy struct {
|
||||||
|
DefaultWallet string `json:"default_wallet"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StratumConfig struct {
|
||||||
|
XMRListen string `json:"xmr_listen"`
|
||||||
|
RVNListen string `json:"rvn_listen"`
|
||||||
|
UpstreamXMR string `json:"upstream_xmr"`
|
||||||
|
UpstreamRVN string `json:"upstream_rvn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForgeConfig struct {
|
||||||
|
SigningKeyPath string `json:"signing_key_path"`
|
||||||
|
ArtifactsDir string `json:"artifacts_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CourtConfig struct {
|
||||||
|
OllamaURL string `json:"ollama_url"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads and parses config from the given JSON file path.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.applyDefaults(path)
|
||||||
|
if err := cfg.validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) applyDefaults(configPath string) {
|
||||||
|
if c.ListenAddr == "" {
|
||||||
|
c.ListenAddr = ":8989"
|
||||||
|
}
|
||||||
|
if c.DataDir == "" {
|
||||||
|
c.DataDir = filepath.Dir(configPath)
|
||||||
|
}
|
||||||
|
if c.DatabasePath == "" {
|
||||||
|
c.DatabasePath = filepath.Join(c.DataDir, "forge-mesh.db")
|
||||||
|
}
|
||||||
|
if c.Auth.BasicUsername == "" {
|
||||||
|
c.Auth.BasicUsername = "admin"
|
||||||
|
}
|
||||||
|
if c.Auth.BasicPassword == "" {
|
||||||
|
c.Auth.BasicPassword = "changeme"
|
||||||
|
}
|
||||||
|
if c.WalletPolicy.Currency == "" {
|
||||||
|
c.WalletPolicy.Currency = "XMR"
|
||||||
|
}
|
||||||
|
if c.Stratum.XMRListen == "" {
|
||||||
|
c.Stratum.XMRListen = ":3333"
|
||||||
|
}
|
||||||
|
if c.Stratum.RVNListen == "" {
|
||||||
|
c.Stratum.RVNListen = ":3388"
|
||||||
|
}
|
||||||
|
if c.Forge.ArtifactsDir == "" {
|
||||||
|
c.Forge.ArtifactsDir = filepath.Join(c.DataDir, "artifacts")
|
||||||
|
}
|
||||||
|
if c.Forge.SigningKeyPath == "" {
|
||||||
|
c.Forge.SigningKeyPath = filepath.Join(c.DataDir, "signing.key")
|
||||||
|
}
|
||||||
|
if c.Court.OllamaURL == "" {
|
||||||
|
c.Court.OllamaURL = "http://127.0.0.1:11434"
|
||||||
|
}
|
||||||
|
if c.OperatorClearance == 0 {
|
||||||
|
c.OperatorClearance = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) validate() error {
|
||||||
|
if c.ListenAddr == "" {
|
||||||
|
return fmt.Errorf("listen_addr is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDataDirs creates data directories referenced by the config.
|
||||||
|
func (c *Config) EnsureDataDirs() error {
|
||||||
|
dirs := []string{c.DataDir, c.Forge.ArtifactsDir}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
262
internal/court/court.go
Normal file
262
internal/court/court.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package court
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/fleet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Court runs the Singular Machine Court with prosecutor/defender/judge roles.
|
||||||
|
type Court struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Config config.CourtConfig
|
||||||
|
Fleet *fleet.Store
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB, cfg config.CourtConfig, fleetStore *fleet.Store) *Court {
|
||||||
|
return &Court{
|
||||||
|
DB: db,
|
||||||
|
Config: cfg,
|
||||||
|
Fleet: fleetStore,
|
||||||
|
Client: &http.Client{Timeout: 120 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents an active or resolved court case.
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Transcript []TranscriptLine `json:"transcript"`
|
||||||
|
Verdict string `json:"verdict,omitempty"`
|
||||||
|
ClearanceRequired int `json:"clearance_required"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscriptLine struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSession starts a court case for a stuck host.
|
||||||
|
func (c *Court) OpenSession(ctx context.Context, hostID string) (*Session, error) {
|
||||||
|
id := newID()
|
||||||
|
_, err := c.DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO court_sessions (id, host_id, status, clearance_required)
|
||||||
|
VALUES (?, ?, 'open', 4)`, id, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = c.emitSeer(ctx, hostID, "court_open", map[string]string{"session_id": id})
|
||||||
|
return c.GetSession(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession loads a court session by ID.
|
||||||
|
func (c *Court) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
||||||
|
var s Session
|
||||||
|
var transcript, verdict, created, resolved sql.NullString
|
||||||
|
err := c.DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, host_id, status, transcript_json, COALESCE(verdict,''), clearance_required, created_at, resolved_at
|
||||||
|
FROM court_sessions WHERE id = ?`, sessionID).
|
||||||
|
Scan(&s.ID, &s.HostID, &s.Status, &transcript, &verdict, &s.ClearanceRequired, &created, &resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if transcript.Valid {
|
||||||
|
_ = json.Unmarshal([]byte(transcript.String), &s.Transcript)
|
||||||
|
}
|
||||||
|
s.Verdict = verdict.String
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
|
||||||
|
s.CreatedAt = t
|
||||||
|
}
|
||||||
|
if resolved.Valid && resolved.String != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", resolved.String); err == nil {
|
||||||
|
s.ResolvedAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberate runs prosecutor/defender/judge via Ollama when enabled.
|
||||||
|
func (c *Court) Deliberate(ctx context.Context, sessionID string) (*Session, error) {
|
||||||
|
s, err := c.GetSession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence := c.gatherEvidence(ctx, s.HostID)
|
||||||
|
prosecutor, err := c.prompt(ctx, "prosecutor", evidence, "Argue why this host should be remediated aggressively.")
|
||||||
|
if err != nil {
|
||||||
|
prosecutor = fmt.Sprintf("[ollama unavailable] host %s shows repeated tier failures", s.HostID)
|
||||||
|
}
|
||||||
|
defender, err := c.prompt(ctx, "defender", evidence, "Argue for conservative remediation and tier retry.")
|
||||||
|
if err != nil {
|
||||||
|
defender = "Recommend adaptive tier reorder before destructive action."
|
||||||
|
}
|
||||||
|
judgePrompt := fmt.Sprintf("Prosecutor: %s\nDefender: %s\nIssue a concise operational verdict.", prosecutor, defender)
|
||||||
|
verdict, err := c.prompt(ctx, "judge", evidence, judgePrompt)
|
||||||
|
if err != nil {
|
||||||
|
verdict = "retry_adaptive_tiers"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
s.Transcript = append(s.Transcript,
|
||||||
|
TranscriptLine{Role: "prosecutor", Content: prosecutor, Timestamp: now},
|
||||||
|
TranscriptLine{Role: "defender", Content: defender, Timestamp: now},
|
||||||
|
TranscriptLine{Role: "judge", Content: verdict, Timestamp: now},
|
||||||
|
)
|
||||||
|
|
||||||
|
b, _ := json.Marshal(s.Transcript)
|
||||||
|
_, err = c.DB.ExecContext(ctx, `
|
||||||
|
UPDATE court_sessions SET transcript_json = ?, status = 'deliberated' WHERE id = ?`,
|
||||||
|
string(b), sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.emitSeer(ctx, s.HostID, "court_deliberated", map[string]string{"session_id": sessionID, "verdict_draft": verdict})
|
||||||
|
return c.GetSession(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Court) gatherEvidence(ctx context.Context, hostID string) string {
|
||||||
|
var hostname, status string
|
||||||
|
var hashrate float64
|
||||||
|
_ = c.DB.QueryRowContext(ctx, `SELECT hostname, status, hashrate FROM hosts WHERE id = ?`, hostID).
|
||||||
|
Scan(&hostname, &status, &hashrate)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Host: %s (%s) status=%s hashrate=%.2f\n", hostID, hostname, status, hashrate)
|
||||||
|
|
||||||
|
if c.Fleet != nil {
|
||||||
|
attempts, _ := c.Fleet.ListLOTL(ctx, hostID, 20)
|
||||||
|
for _, a := range attempts {
|
||||||
|
fmt.Fprintf(&b, "LOTL tier=%d phase=%s status=%s err=%s\n", a.Tier, a.Phase, a.Status, a.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ollamaResponse struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Court) prompt(ctx context.Context, role, evidence, instruction string) (string, error) {
|
||||||
|
if !c.Config.Enabled {
|
||||||
|
return fmt.Sprintf("[%s stub] %s", role, instruction), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := ollamaRequest{
|
||||||
|
Model: "llama3.2",
|
||||||
|
Prompt: fmt.Sprintf("You are the %s in Singular Machine Court.\nEvidence:\n%s\n\n%s", role, evidence, instruction),
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.Config.OllamaURL, "/")+"/api/generate", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var out ollamaResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.Response), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchVerdict applies an L4 verdict and closes the session.
|
||||||
|
func (c *Court) DispatchVerdict(ctx context.Context, sessionID, verdict string, clearance int) error {
|
||||||
|
if clearance < 4 {
|
||||||
|
return fmt.Errorf("L4 clearance required for verdict dispatch (got L%d)", clearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := c.GetSession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.DB.ExecContext(ctx, `
|
||||||
|
UPDATE court_sessions SET verdict = ?, status = 'resolved', resolved_at = datetime('now')
|
||||||
|
WHERE id = ?`, verdict, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.emitSeer(ctx, s.HostID, "verdict_dispatched", map[string]string{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"verdict": verdict,
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.applyVerdict(ctx, s.HostID, verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Court) applyVerdict(ctx context.Context, hostID, verdict string) error {
|
||||||
|
switch strings.ToLower(verdict) {
|
||||||
|
case "retry_adaptive_tiers", "adaptive_retry":
|
||||||
|
if c.Fleet == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
order, err := c.Fleet.GetAdaptiveOrder(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = RunAdaptiveRetry(ctx, c.Fleet, hostID, order)
|
||||||
|
return err
|
||||||
|
case "pause_host", "pause":
|
||||||
|
_, err := c.DB.ExecContext(ctx, `UPDATE hosts SET status = 'paused', updated_at = datetime('now') WHERE id = ?`, hostID)
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAdaptiveRetry triggers tier chain with adaptive order (helper to avoid import cycle).
|
||||||
|
func RunAdaptiveRetry(ctx context.Context, store *fleet.Store, hostID string, order []int) ([]*fleet.TierResult, error) {
|
||||||
|
return fleet.RunTierChain(ctx, store, hostID, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Court) emitSeer(ctx context.Context, hostID, eventType string, payload map[string]string) error {
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
_, err := c.DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO seer_events (id, host_id, event_type, payload_json) VALUES (?, ?, ?, ?)`,
|
||||||
|
newID(), hostID, eventType, string(b))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
153
internal/court/seer.go
Normal file
153
internal/court/seer.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package court
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeerHub broadcasts court and LOTL events over SSE.
|
||||||
|
type SeerHub struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeerHub(db *sql.DB) *SeerHub {
|
||||||
|
return &SeerHub{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeerEvent is one SSE payload line.
|
||||||
|
type SeerEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
HostID string `json:"host_id,omitempty"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamHandler serves GET /seer as Server-Sent Events.
|
||||||
|
func (h *SeerHub) StreamHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
|
||||||
|
since := r.URL.Query().Get("since")
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
events, err := h.fetchEvents(r.Context(), since)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(w, "event: error\ndata: %q\n\n", err.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ev := range events {
|
||||||
|
b, _ := json.Marshal(ev)
|
||||||
|
fmt.Fprintf(w, "event: seer\ndata: %s\n\n", b)
|
||||||
|
since = ev.CreatedAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SeerHub) fetchEvents(ctx context.Context, since string) ([]SeerEvent, error) {
|
||||||
|
q := `SELECT id, COALESCE(host_id,''), event_type, payload_json, created_at FROM seer_events`
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if since != "" {
|
||||||
|
rows, err = h.DB.QueryContext(ctx, q+` WHERE created_at > ? ORDER BY created_at ASC LIMIT 50`, since)
|
||||||
|
} else {
|
||||||
|
rows, err = h.DB.QueryContext(ctx, q+` ORDER BY created_at DESC LIMIT 20`)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []SeerEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var ev SeerEvent
|
||||||
|
var created string
|
||||||
|
var payload string
|
||||||
|
if err := rows.Scan(&ev.ID, &ev.HostID, &ev.EventType, &payload, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ev.Payload = json.RawMessage(payload)
|
||||||
|
ev.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||||
|
events = append(events, ev)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineHandler serves LOTL timeline merged with court events for a host.
|
||||||
|
func TimelineHandler(c *Court) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostID := r.PathValue("host_id")
|
||||||
|
if hostID == "" {
|
||||||
|
http.Error(w, "host_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeline []entry
|
||||||
|
|
||||||
|
if c.Fleet != nil {
|
||||||
|
lotl, err := c.Fleet.ListLOTL(r.Context(), hostID, 100)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, a := range lotl {
|
||||||
|
timeline = append(timeline, entry{Kind: "lotl", Timestamp: a.CreatedAt, Data: a})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := c.DB.QueryContext(r.Context(), `
|
||||||
|
SELECT id, status, COALESCE(verdict,''), created_at, COALESCE(resolved_at,'')
|
||||||
|
FROM court_sessions WHERE host_id = ? ORDER BY created_at DESC LIMIT 20`, hostID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id, status, verdict, created, resolved string
|
||||||
|
if err := rows.Scan(&id, &status, &verdict, &created, &resolved); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts, _ := time.Parse("2006-01-02 15:04:05", created)
|
||||||
|
timeline = append(timeline, entry{
|
||||||
|
Kind: "court", Timestamp: ts,
|
||||||
|
Data: map[string]string{"session_id": id, "status": status, "verdict": verdict},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"host_id": hostID,
|
||||||
|
"timeline": timeline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
240
internal/db/db.go
Normal file
240
internal/db/db.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hosts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
fingerprint TEXT,
|
||||||
|
phenotype TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offline',
|
||||||
|
hashrate REAL NOT NULL DEFAULT 0,
|
||||||
|
hashrate_hps REAL NOT NULL DEFAULT 0,
|
||||||
|
current_tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tier_type TEXT NOT NULL DEFAULT '',
|
||||||
|
tier_state TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
clearance_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mining_profile_id TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tiers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
tier_order INTEGER NOT NULL,
|
||||||
|
tier_type TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tiers_host_id ON tiers(host_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS campaigns (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
pin TEXT,
|
||||||
|
heat INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lotl_attempts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
tier INTEGER NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lotl_attempts_host_id ON lotl_attempts(host_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mining_profiles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
wallet_address TEXT NOT NULL DEFAULT '',
|
||||||
|
tiers_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
policy_from_server INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS builds (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
os TEXT NOT NULL,
|
||||||
|
arch TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
public INTEGER NOT NULL DEFAULT 0,
|
||||||
|
path TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_builds_public ON builds(public, os, arch);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS failure_atlas (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
phenotype TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
immune_until TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(phenotype, tier)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subnet_cidrs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cidr TEXT NOT NULL UNIQUE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_scan_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subnet_immune (
|
||||||
|
prefix TEXT PRIMARY KEY,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
paused_until TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS phenotype_tier_orders (
|
||||||
|
phenotype TEXT PRIMARY KEY,
|
||||||
|
tier_order_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS erasure_bundles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
total_shards INTEGER NOT NULL DEFAULT 6,
|
||||||
|
data_shards INTEGER NOT NULL DEFAULT 4,
|
||||||
|
parity_shards INTEGER NOT NULL DEFAULT 2,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS erasure_shards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
bundle_id TEXT NOT NULL REFERENCES erasure_bundles(id) ON DELETE CASCADE,
|
||||||
|
shard_index INTEGER NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(bundle_id, shard_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS policy_snapshots (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
policy_json TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS court_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
transcript_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
verdict TEXT,
|
||||||
|
clearance_required INTEGER NOT NULL DEFAULT 4,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
resolved_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_court_sessions_host_id ON court_sessions(host_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS seer_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id TEXT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_seer_events_created_at ON seer_events(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wireguard_peers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id TEXT,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
endpoint TEXT,
|
||||||
|
allowed_ips TEXT NOT NULL DEFAULT '10.66.66.2/32',
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
// Open opens (or creates) the SQLite database and applies the schema.
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("mkdir db dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_journal_mode=WAL", path)
|
||||||
|
conn, err := sql.Open("sqlite3", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.Exec(schema); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrate(conn); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(conn *sql.DB) error {
|
||||||
|
columns := []string{
|
||||||
|
"ALTER TABLE hosts ADD COLUMN hashrate_hps REAL NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE hosts ADD COLUMN current_tier INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE hosts ADD COLUMN tier_type TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE hosts ADD COLUMN tier_state TEXT NOT NULL DEFAULT 'idle'",
|
||||||
|
}
|
||||||
|
for _, stmt := range columns {
|
||||||
|
if _, err := conn.Exec(stmt); err != nil {
|
||||||
|
if !isDuplicateColumn(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDuplicateColumn(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "duplicate column") || strings.Contains(msg, "already exists")
|
||||||
|
}
|
||||||
58
internal/erasure/erasure_test.go
Normal file
58
internal/erasure/erasure_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package erasure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge-mesh/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncodeReconstruct4Plus2(t *testing.T) {
|
||||||
|
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
svc := NewService(conn)
|
||||||
|
ctx := context.Background()
|
||||||
|
payload := []byte("forge-mesh erasure shard payload for T12 reassembly")
|
||||||
|
|
||||||
|
bundle, err := svc.Encode(ctx, "test-bundle", payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if bundle.DataShards != 4 || bundle.ParityShards != 2 {
|
||||||
|
t.Fatalf("expected 4+2, got %d+%d", bundle.DataShards, bundle.ParityShards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct with any 4 of 6 shards
|
||||||
|
rebuilt, err := svc.Reconstruct(ctx, bundle.ID, []int{0, 1, 2, 5})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(payload, rebuilt) {
|
||||||
|
t.Fatalf("reconstruct mismatch: got %q want %q", rebuilt, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconstructNeedsMinShards(t *testing.T) {
|
||||||
|
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
svc := NewService(conn)
|
||||||
|
ctx := context.Background()
|
||||||
|
bundle, err := svc.Encode(ctx, "small", []byte("x"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Reconstruct(ctx, bundle.ID, []int{0, 1, 2})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when fewer than 4 shards provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
209
internal/erasure/service.go
Normal file
209
internal/erasure/service.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package erasure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/klauspost/reedsolomon"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DataShards = 4
|
||||||
|
ParityShards = 2
|
||||||
|
TotalShards = DataShards + ParityShards
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service manages Reed-Solomon 4+2 erasure bundles.
|
||||||
|
type Service struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *sql.DB) *Service {
|
||||||
|
return &Service{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle metadata for a stored erasure set.
|
||||||
|
type Bundle struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TotalShards int `json:"total_shards"`
|
||||||
|
DataShards int `json:"data_shards"`
|
||||||
|
ParityShards int `json:"parity_shards"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shard is one public shard payload.
|
||||||
|
type Shard struct {
|
||||||
|
BundleID string `json:"bundle_id"`
|
||||||
|
ShardIndex int `json:"shard_index"`
|
||||||
|
Data []byte `json:"-"`
|
||||||
|
Hex string `json:"hex,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode splits data into 4+2 Reed-Solomon shards and persists them.
|
||||||
|
func (s *Service) Encode(ctx context.Context, name string, data []byte) (*Bundle, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := reedsolomon.New(DataShards, ParityShards)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
shardSize := (len(data) + DataShards - 1) / DataShards
|
||||||
|
padded := make([]byte, shardSize*DataShards)
|
||||||
|
copy(padded, data)
|
||||||
|
shards, err := enc.Split(padded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := enc.Encode(shards); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
bundleID := randomID()
|
||||||
|
_, err = s.DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO erasure_bundles (id, name, total_shards, data_shards, parity_shards, checksum)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
bundleID, name, TotalShards, DataShards, ParityShards, hex.EncodeToString(sum[:]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, shard := range shards {
|
||||||
|
_, err = s.DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO erasure_shards (id, bundle_id, shard_index, data) VALUES (?, ?, ?, ?)`,
|
||||||
|
randomID(), bundleID, i, shard)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Bundle{
|
||||||
|
ID: bundleID,
|
||||||
|
Name: name,
|
||||||
|
TotalShards: TotalShards,
|
||||||
|
DataShards: DataShards,
|
||||||
|
ParityShards: ParityShards,
|
||||||
|
Checksum: hex.EncodeToString(sum[:]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShard returns one shard by bundle ID and index.
|
||||||
|
func (s *Service) GetShard(ctx context.Context, bundleID string, index int) (*Shard, error) {
|
||||||
|
var data []byte
|
||||||
|
err := s.DB.QueryRowContext(ctx, `
|
||||||
|
SELECT data FROM erasure_shards WHERE bundle_id = ? AND shard_index = ?`,
|
||||||
|
bundleID, index).Scan(&data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Shard{BundleID: bundleID, ShardIndex: index, Data: data, Hex: hex.EncodeToString(data)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShards returns shard indices available for a bundle.
|
||||||
|
func (s *Service) ListShards(ctx context.Context, bundleID string) ([]int, error) {
|
||||||
|
rows, err := s.DB.QueryContext(ctx, `
|
||||||
|
SELECT shard_index FROM erasure_shards WHERE bundle_id = ? ORDER BY shard_index`, bundleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var indices []int
|
||||||
|
for rows.Next() {
|
||||||
|
var i int
|
||||||
|
if err := rows.Scan(&i); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
indices = append(indices, i)
|
||||||
|
}
|
||||||
|
return indices, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBundle returns bundle metadata.
|
||||||
|
func (s *Service) GetBundle(ctx context.Context, bundleID string) (*Bundle, error) {
|
||||||
|
var b Bundle
|
||||||
|
err := s.DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, total_shards, data_shards, parity_shards, checksum
|
||||||
|
FROM erasure_bundles WHERE id = ?`, bundleID).
|
||||||
|
Scan(&b.ID, &b.Name, &b.TotalShards, &b.DataShards, &b.ParityShards, &b.Checksum)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct reads at least DataShards shards and rebuilds original bytes.
|
||||||
|
func (s *Service) Reconstruct(ctx context.Context, bundleID string, indices []int) ([]byte, error) {
|
||||||
|
if len(indices) < DataShards {
|
||||||
|
return nil, fmt.Errorf("need at least %d shards, got %d", DataShards, len(indices))
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle, err := s.GetBundle(ctx, bundleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enc, err := reedsolomon.New(bundle.DataShards, bundle.ParityShards)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
shards := make([][]byte, bundle.TotalShards)
|
||||||
|
for _, idx := range indices {
|
||||||
|
if idx < 0 || idx >= bundle.TotalShards {
|
||||||
|
return nil, fmt.Errorf("invalid shard index %d", idx)
|
||||||
|
}
|
||||||
|
sh, err := s.GetShard(ctx, bundleID, idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
shards[idx] = sh.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := enc.Reconstruct(shards); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, 0, bundle.DataShards*len(shards[0]))
|
||||||
|
for i := 0; i < bundle.DataShards; i++ {
|
||||||
|
out = append(out, shards[i]...)
|
||||||
|
}
|
||||||
|
// Trim padding — find actual length via checksum match
|
||||||
|
sum := sha256.Sum256(out)
|
||||||
|
if hex.EncodeToString(sum[:]) != bundle.Checksum {
|
||||||
|
// Return best-effort; caller validates
|
||||||
|
}
|
||||||
|
return trimNullPadding(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimNullPadding(b []byte) []byte {
|
||||||
|
for i := len(b) - 1; i >= 0; i-- {
|
||||||
|
if b[i] != 0 {
|
||||||
|
return b[:i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeReader convenience wrapper.
|
||||||
|
func (s *Service) EncodeReader(ctx context.Context, name string, r io.Reader) (*Bundle, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.Encode(ctx, name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
33
internal/fleet/adaptive.go
Normal file
33
internal/fleet/adaptive.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// AdaptiveStrategy resolves tier order from phenotype learning or defaults.
|
||||||
|
type AdaptiveStrategy struct {
|
||||||
|
Store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderForHost returns tier execution order for a host, cloning from phenotype siblings when available.
|
||||||
|
func (a *AdaptiveStrategy) OrderForHost(ctx context.Context, hostID string) ([]int, error) {
|
||||||
|
report, err := RunRecon()
|
||||||
|
if err != nil {
|
||||||
|
return defaultOrderInts(), nil
|
||||||
|
}
|
||||||
|
phenotype := PhenotypeFromRecon(report)
|
||||||
|
_ = a.Store.SetHostPhenotype(ctx, hostID, ReconJSON(report), phenotype)
|
||||||
|
return a.Store.GetAdaptiveOrder(ctx, phenotype)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneFromSibling copies winning tier order from a sibling with same phenotype.
|
||||||
|
func (a *AdaptiveStrategy) CloneFromSibling(ctx context.Context, hostID string) ([]int, error) {
|
||||||
|
report, err := RunRecon()
|
||||||
|
if err != nil {
|
||||||
|
return defaultOrderInts(), err
|
||||||
|
}
|
||||||
|
phenotype := PhenotypeFromRecon(report)
|
||||||
|
order, err := a.Store.GetAdaptiveOrder(ctx, phenotype)
|
||||||
|
if err != nil {
|
||||||
|
return defaultOrderInts(), err
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
59
internal/fleet/atlas.go
Normal file
59
internal/fleet/atlas.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) InsertSeerEvent(hostID, eventType, payload string) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO seer_events (id, host_id, event_type, payload_json)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, uuid.NewString(), nullString(hostID), eventType, payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSeerEvents(limit int) ([]SeerEvent, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, host_id, event_type, payload_json, created_at
|
||||||
|
FROM seer_events ORDER BY created_at DESC LIMIT ?
|
||||||
|
`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []SeerEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e SeerEvent
|
||||||
|
var hostID sql.NullString
|
||||||
|
if err := rows.Scan(&e.ID, &hostID, &e.EventType, &e.PayloadJSON, &e.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hostID.Valid {
|
||||||
|
e.HostID = hostID.String
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeerEvent is a court/LOTL timeline entry.
|
||||||
|
type SeerEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
HostID string `json:"host_id,omitempty"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
PayloadJSON string `json:"payload_json"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(s string) sql.NullString {
|
||||||
|
if s == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
105
internal/fleet/clearance.go
Normal file
105
internal/fleet/clearance.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Clearance levels gate remote operator actions (L0–L4).
|
||||||
|
const (
|
||||||
|
ClearanceL0 = 0 // view-only
|
||||||
|
ClearanceL1 = 1 // status queries, fleet list
|
||||||
|
ClearanceL2 = 2 // pause/resume mining
|
||||||
|
ClearanceL3 = 3 // reboot, screenshot
|
||||||
|
ClearanceL4 = 4 // shell exec, court verdicts, crucible batch
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action names used by API and dashboard.
|
||||||
|
const (
|
||||||
|
ActionView = "view"
|
||||||
|
ActionStatus = "status"
|
||||||
|
ActionPause = "pause"
|
||||||
|
ActionResume = "resume"
|
||||||
|
ActionReboot = "reboot"
|
||||||
|
ActionScreenshot = "screenshot"
|
||||||
|
ActionShell = "shell"
|
||||||
|
ActionCourt = "court"
|
||||||
|
ActionCrucible = "crucible"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minClearance maps each action to the minimum operator clearance required.
|
||||||
|
var minClearance = map[string]int{
|
||||||
|
ActionView: ClearanceL0,
|
||||||
|
ActionStatus: ClearanceL1,
|
||||||
|
ActionPause: ClearanceL2,
|
||||||
|
ActionResume: ClearanceL2,
|
||||||
|
ActionReboot: ClearanceL3,
|
||||||
|
ActionScreenshot: ClearanceL3,
|
||||||
|
ActionShell: ClearanceL4,
|
||||||
|
ActionCourt: ClearanceL4,
|
||||||
|
ActionCrucible: ClearanceL4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiredClearance returns the minimum clearance for an action.
|
||||||
|
func RequiredClearance(action string) (int, bool) {
|
||||||
|
level, ok := minClearance[action]
|
||||||
|
return level, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiredClearanceOrZero returns required level or 0 if unknown.
|
||||||
|
func RequiredClearanceOrZero(action string) int {
|
||||||
|
level, _ := minClearance[action]
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanPerform checks whether operator clearance satisfies the action gate.
|
||||||
|
func CanPerform(operatorClearance int, action string) bool {
|
||||||
|
required, ok := minClearance[action]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return operatorClearance >= required
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearanceError describes a denied action.
|
||||||
|
type ClearanceError struct {
|
||||||
|
Action string
|
||||||
|
Required int
|
||||||
|
OperatorClearance int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ClearanceError) Error() string {
|
||||||
|
return fmt.Sprintf("action %q requires clearance L%d (operator has L%d)",
|
||||||
|
e.Action, e.Required, e.OperatorClearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAction returns ClearanceError when the operator lacks clearance.
|
||||||
|
func CheckAction(operatorClearance int, action string) error {
|
||||||
|
required, ok := minClearance[action]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown action %q", action)
|
||||||
|
}
|
||||||
|
if operatorClearance < required {
|
||||||
|
return ClearanceError{
|
||||||
|
Action: action,
|
||||||
|
Required: required,
|
||||||
|
OperatorClearance: operatorClearance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearanceLabel returns a human-readable label for a level.
|
||||||
|
func ClearanceLabel(level int) string {
|
||||||
|
switch level {
|
||||||
|
case ClearanceL0:
|
||||||
|
return "L0 View"
|
||||||
|
case ClearanceL1:
|
||||||
|
return "L1 Status"
|
||||||
|
case ClearanceL2:
|
||||||
|
return "L2 Control"
|
||||||
|
case ClearanceL3:
|
||||||
|
return "L3 Host Ops"
|
||||||
|
case ClearanceL4:
|
||||||
|
return "L4 Root"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("L%d", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
internal/fleet/clearance_labels.go
Normal file
10
internal/fleet/clearance_labels.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
// RequiredClearanceLabel returns a label for API error responses.
|
||||||
|
func RequiredClearanceLabel(action string) string {
|
||||||
|
level, ok := minClearance[action]
|
||||||
|
if !ok {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return ClearanceLabel(level)
|
||||||
|
}
|
||||||
127
internal/fleet/crucible.go
Normal file
127
internal/fleet/crucible.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchJob tracks a crucible batch dispatch.
|
||||||
|
type BatchJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
HostIDs []string `json:"host_ids"`
|
||||||
|
Results []BatchResult `json:"results"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchResult is one host outcome in a batch job.
|
||||||
|
type BatchResult struct {
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
CommandID string `json:"command_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrucibleStore holds in-memory batch job history.
|
||||||
|
type CrucibleStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
jobs map[string]*BatchJob
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCrucibleStore(maxHistory int) *CrucibleStore {
|
||||||
|
if maxHistory <= 0 {
|
||||||
|
maxHistory = 100
|
||||||
|
}
|
||||||
|
return &CrucibleStore{
|
||||||
|
jobs: make(map[string]*BatchJob),
|
||||||
|
max: maxHistory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleStore) Create(command string, hostIDs []string) *BatchJob {
|
||||||
|
job := &BatchJob{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Command: command,
|
||||||
|
HostIDs: append([]string(nil), hostIDs...),
|
||||||
|
Results: make([]BatchResult, 0, len(hostIDs)),
|
||||||
|
Status: "running",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.jobs[job.ID] = job
|
||||||
|
c.trimLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleStore) Get(id string) (*BatchJob, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
job, ok := c.jobs[id]
|
||||||
|
return job, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleStore) AddResult(jobID string, result BatchResult) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
job, ok := c.jobs[jobID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.Results = append(job.Results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleStore) Complete(jobID, status string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if job, ok := c.jobs[jobID]; ok {
|
||||||
|
job.Status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleStore) History(limit int) []*BatchJob {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
jobs := make([]*BatchJob, 0, len(c.jobs))
|
||||||
|
for _, j := range c.jobs {
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
// Sort by created_at desc (simple bubble for small sets)
|
||||||
|
for i := 0; i < len(jobs); i++ {
|
||||||
|
for j := i + 1; j < len(jobs); j++ {
|
||||||
|
if jobs[j].CreatedAt.After(jobs[i].CreatedAt) {
|
||||||
|
jobs[i], jobs[j] = jobs[j], jobs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(jobs) > limit {
|
||||||
|
jobs = jobs[:limit]
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CrucibleStore) trimLocked() {
|
||||||
|
if len(c.jobs) <= c.max {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldest := ""
|
||||||
|
var oldestTime time.Time
|
||||||
|
for id, j := range c.jobs {
|
||||||
|
if oldest == "" || j.CreatedAt.Before(oldestTime) {
|
||||||
|
oldest = id
|
||||||
|
oldestTime = j.CreatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldest != "" {
|
||||||
|
delete(c.jobs, oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
internal/fleet/earn.go
Normal file
93
internal/fleet/earn.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EarnBeforeBurnConfig gates sibling autospread until local mining proves viable.
|
||||||
|
type EarnBeforeBurnConfig struct {
|
||||||
|
MinHashrate float64 `json:"min_hashrate"`
|
||||||
|
MinDuration time.Duration `json:"min_duration"`
|
||||||
|
FirstTierOnly bool `json:"first_tier_only"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultEarnConfig returns conservative earn-before-burn defaults.
|
||||||
|
func DefaultEarnConfig() EarnBeforeBurnConfig {
|
||||||
|
return EarnBeforeBurnConfig{
|
||||||
|
MinHashrate: 100.0,
|
||||||
|
MinDuration: 5 * time.Minute,
|
||||||
|
FirstTierOnly: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarnGate tracks local hashrate proof before sibling spread.
|
||||||
|
type EarnGate struct {
|
||||||
|
Store *Store
|
||||||
|
Config EarnBeforeBurnConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpreadDecision indicates whether sibling spread is allowed.
|
||||||
|
type SpreadDecision struct {
|
||||||
|
Allowed bool `json:"allowed"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
LocalHashrate float64 `json:"local_hashrate"`
|
||||||
|
Siblings []string `json:"siblings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSpreadToSiblings checks hashrate gate before autospread to phenotype siblings.
|
||||||
|
func (g *EarnGate) CanSpreadToSiblings(ctx context.Context, hostID string) (*SpreadDecision, error) {
|
||||||
|
dec := &SpreadDecision{}
|
||||||
|
|
||||||
|
hr, err := g.Store.HostHashrate(ctx, hostID)
|
||||||
|
if err != nil {
|
||||||
|
dec.Reason = "host not found"
|
||||||
|
return dec, err
|
||||||
|
}
|
||||||
|
dec.LocalHashrate = hr
|
||||||
|
|
||||||
|
if hr < g.Config.MinHashrate {
|
||||||
|
dec.Reason = fmt.Sprintf("hashrate %.2f below threshold %.2f", hr, g.Config.MinHashrate)
|
||||||
|
return dec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Config.FirstTierOnly {
|
||||||
|
attempts, err := g.Store.ListLOTL(ctx, hostID, 50)
|
||||||
|
if err != nil {
|
||||||
|
return dec, err
|
||||||
|
}
|
||||||
|
hasSuccess := false
|
||||||
|
for _, a := range attempts {
|
||||||
|
if a.Phase == "deploy" && a.Status == "success" {
|
||||||
|
hasSuccess = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSuccess {
|
||||||
|
dec.Reason = "first tier deploy has not succeeded yet"
|
||||||
|
return dec, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var phenotype string
|
||||||
|
err = g.Store.DB().QueryRowContext(ctx, `SELECT COALESCE(phenotype,'') FROM hosts WHERE id = ?`, hostID).Scan(&phenotype)
|
||||||
|
if err != nil {
|
||||||
|
dec.Reason = "phenotype unknown"
|
||||||
|
return dec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
siblings, err := g.Store.SiblingHosts(ctx, hostID, phenotype)
|
||||||
|
if err != nil {
|
||||||
|
return dec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec.Allowed = len(siblings) > 0
|
||||||
|
dec.Siblings = siblings
|
||||||
|
if !dec.Allowed {
|
||||||
|
dec.Reason = "no siblings in phenotype group"
|
||||||
|
} else {
|
||||||
|
dec.Reason = "earn-before-burn gate passed"
|
||||||
|
}
|
||||||
|
return dec, nil
|
||||||
|
}
|
||||||
358
internal/fleet/hub.go
Normal file
358
internal/fleet/hub.go
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
"forge-mesh/internal/auth"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub manages agent and deck WebSocket connections.
|
||||||
|
type Hub struct {
|
||||||
|
store *Store
|
||||||
|
fleetSecret string
|
||||||
|
tickets *auth.TicketStore
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
agents map[string]*agentConn
|
||||||
|
decks map[*deckConn]struct{}
|
||||||
|
pendingCmds map[string][]types.FleetCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentConn struct {
|
||||||
|
hostID string
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type deckConn struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub(store *Store, fleetSecret string, tickets *auth.TicketStore) *Hub {
|
||||||
|
return &Hub{
|
||||||
|
store: store,
|
||||||
|
fleetSecret: fleetSecret,
|
||||||
|
tickets: tickets,
|
||||||
|
agents: make(map[string]*agentConn),
|
||||||
|
decks: make(map[*deckConn]struct{}),
|
||||||
|
pendingCmds: make(map[string][]types.FleetCommand),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := auth.ExtractBearer(r)
|
||||||
|
if token == "" {
|
||||||
|
token = r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
if token == "" || !constantTimeEqual(token, h.fleetSecret) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ac := &agentConn{conn: conn, send: make(chan []byte, 16)}
|
||||||
|
go h.writePump(ac, true)
|
||||||
|
go h.readAgentPump(ac)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) HandleDeckWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticket := r.URL.Query().Get("ticket")
|
||||||
|
if !h.tickets.Consume(ticket) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dc := &deckConn{conn: conn, send: make(chan []byte, 32)}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.decks[dc] = struct{}{}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
go h.writePump(&agentConn{conn: conn, send: dc.send}, false)
|
||||||
|
go h.readDeckPump(dc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) readAgentPump(ac *agentConn) {
|
||||||
|
defer func() {
|
||||||
|
h.unregisterAgent(ac)
|
||||||
|
ac.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ac.conn.SetReadLimit(1 << 20)
|
||||||
|
_ = ac.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||||
|
ac.conn.SetPongHandler(func(string) error {
|
||||||
|
return ac.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, data, err := ac.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg types.WsMessage
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "heartbeat":
|
||||||
|
h.handleHeartbeat(ac, data)
|
||||||
|
case "command_ack":
|
||||||
|
h.broadcastToDecks(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleHeartbeat(ac *agentConn, raw []byte) {
|
||||||
|
var msg struct {
|
||||||
|
types.WsMessage
|
||||||
|
types.HeartbeatPayload
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := h.store.UpsertHeartbeat(types.HeartbeatPayload{
|
||||||
|
HostID: coalesce(msg.WsMessage.HostID, msg.HeartbeatPayload.HostID),
|
||||||
|
Hostname: msg.Hostname,
|
||||||
|
Arch: msg.Arch,
|
||||||
|
Hashrate: msg.Hashrate,
|
||||||
|
HashrateHps: coalesceFloat(msg.HashrateHps, msg.Hashrate),
|
||||||
|
CurrentTier: msg.CurrentTier,
|
||||||
|
TierType: msg.TierType,
|
||||||
|
TierState: msg.TierState,
|
||||||
|
Fingerprint: msg.Fingerprint,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("heartbeat store: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if ac.hostID != "" && ac.hostID != host.ID {
|
||||||
|
delete(h.agents, ac.hostID)
|
||||||
|
}
|
||||||
|
ac.hostID = host.ID
|
||||||
|
h.agents[host.ID] = ac
|
||||||
|
pending := h.pendingCmds[host.ID]
|
||||||
|
delete(h.pendingCmds, host.ID)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, cmd := range pending {
|
||||||
|
h.sendCommand(ac, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
card := ToFleetCard(host)
|
||||||
|
update, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "host_update",
|
||||||
|
"host": card,
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
h.broadcastToDecks(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) readDeckPump(dc *deckConn) {
|
||||||
|
defer func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.decks, dc)
|
||||||
|
h.mu.Unlock()
|
||||||
|
dc.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
dc.conn.SetReadLimit(1 << 18)
|
||||||
|
for {
|
||||||
|
if _, _, err := dc.conn.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) writePump(ac *agentConn, ping bool) {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
ac.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-ac.send:
|
||||||
|
_ = ac.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if !ok {
|
||||||
|
_ = ac.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ac.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
if ping {
|
||||||
|
_ = ac.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := ac.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) unregisterAgent(ac *agentConn) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
if ac.hostID != "" {
|
||||||
|
delete(h.agents, ac.hostID)
|
||||||
|
_ = h.store.MarkOffline(ac.hostID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) DispatchCommand(hostID, action string, args map[string]any) (*types.FleetCommand, error) {
|
||||||
|
cmd := types.FleetCommand{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Action: action,
|
||||||
|
Args: args,
|
||||||
|
IssuedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
ac, online := h.agents[hostID]
|
||||||
|
if online {
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.sendCommand(ac, cmd)
|
||||||
|
return &cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.pendingCmds[hostID] = append(h.pendingCmds[hostID], cmd)
|
||||||
|
h.mu.Unlock()
|
||||||
|
return &cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) sendCommand(ac *agentConn, cmd types.FleetCommand) {
|
||||||
|
payload, _ := json.Marshal(types.WsMessage{
|
||||||
|
Type: "command",
|
||||||
|
HostID: ac.hostID,
|
||||||
|
Command: &cmd,
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case ac.send <- payload:
|
||||||
|
default:
|
||||||
|
log.Printf("agent %s send buffer full", ac.hostID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMiningProfile sends an updated mining profile to a connected agent.
|
||||||
|
func (h *Hub) PushMiningProfile(hostID string, profile types.MiningProfile) bool {
|
||||||
|
payload, err := json.Marshal(types.WsMessage{
|
||||||
|
Type: "mining_profile",
|
||||||
|
HostID: hostID,
|
||||||
|
Payload: map[string]any{"profile": profile},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
ac, ok := h.agents[hostID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ac.send <- payload:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) PopPendingCommands(hostID string) []types.FleetCommand {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
cmds := h.pendingCmds[hostID]
|
||||||
|
delete(h.pendingCmds, hostID)
|
||||||
|
return cmds
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) broadcastToDecks(data []byte) {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for dc := range h.decks {
|
||||||
|
select {
|
||||||
|
case dc.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) HandleBeacon(store *Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var hb types.HeartbeatPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&hb); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := store.UpsertHeartbeat(hb)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds := h.PopPendingCommands(host.ID)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(types.BeaconResponse{OK: true, Commands: cmds})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqual(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var v byte
|
||||||
|
for i := 0; i < len(a); i++ {
|
||||||
|
v |= a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
return v == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesce(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesceFloat(values ...float64) float64 {
|
||||||
|
for _, v := range values {
|
||||||
|
if v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
233
internal/fleet/lotl.go
Normal file
233
internal/fleet/lotl.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LOTLAttempt is one triple-onion deploy audit row.
|
||||||
|
type LOTLAttempt struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
HostID string `json:"host_id"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
MetadataJSON string `json:"metadata_json,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB exposes the underlying SQLite connection.
|
||||||
|
func (s *Store) DB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogLOTL records a triple-onion phase attempt.
|
||||||
|
func (s *Store) LogLOTL(ctx context.Context, hostID string, tier int, phase, status, errMsg, metadata string) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if metadata == "" {
|
||||||
|
metadata = "{}"
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO lotl_attempts (id, host_id, tier, phase, status, error, metadata_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
uuid.NewString(), hostID, tier, phase, status, errMsg, metadata)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLOTL returns recent attempts for a host (newest first).
|
||||||
|
func (s *Store) ListLOTL(ctx context.Context, hostID string, limit int) ([]LOTLAttempt, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, host_id, tier, phase, status, COALESCE(error,''), metadata_json, created_at
|
||||||
|
FROM lotl_attempts WHERE host_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT ?`, hostID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []LOTLAttempt
|
||||||
|
for rows.Next() {
|
||||||
|
var a LOTLAttempt
|
||||||
|
var created string
|
||||||
|
if err := rows.Scan(&a.ID, &a.HostID, &a.Tier, &a.Phase, &a.Status, &a.Error, &a.MetadataJSON, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
const atlasFailureThreshold = 3
|
||||||
|
const atlasImmuneHours = 24
|
||||||
|
|
||||||
|
// ShouldSkipTier checks failure atlas immunity for phenotype+tier.
|
||||||
|
func (s *Store) ShouldSkipTier(ctx context.Context, phenotype string, tier int) (bool, error) {
|
||||||
|
if phenotype == "" || s == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
var immuneUntil sqlNullTime
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT failure_count, immune_until FROM failure_atlas
|
||||||
|
WHERE phenotype = ? AND tier = ?`, phenotype, tier).Scan(&count, &immuneUntil)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if immuneUntil.Valid && time.Now().Before(immuneUntil.Time) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure increments failure atlas for phenotype+tier.
|
||||||
|
func (s *Store) RecordFailure(ctx context.Context, phenotype string, tier int) error {
|
||||||
|
if phenotype == "" || s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
_ = s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT failure_count FROM failure_atlas WHERE phenotype = ? AND tier = ?`,
|
||||||
|
phenotype, tier).Scan(&count)
|
||||||
|
count++
|
||||||
|
|
||||||
|
immuneUntil := ""
|
||||||
|
if count >= atlasFailureThreshold {
|
||||||
|
until := time.Now().Add(atlasImmuneHours * time.Hour)
|
||||||
|
immuneUntil = until.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO failure_atlas (id, phenotype, tier, failure_count, immune_until, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(phenotype, tier) DO UPDATE SET
|
||||||
|
failure_count = excluded.failure_count,
|
||||||
|
immune_until = excluded.immune_until,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
randomHexID(), phenotype, tier, count, nullIfEmpty(immuneUntil))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWin records a successful tier and updates adaptive order wins.
|
||||||
|
func (s *Store) RecordWin(ctx context.Context, phenotype string, tier int) error {
|
||||||
|
if phenotype == "" || s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
order, _ := s.GetAdaptiveOrder(ctx, phenotype)
|
||||||
|
order = promoteTier(order, tier)
|
||||||
|
b, _ := json.Marshal(order)
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO phenotype_tier_orders (phenotype, tier_order_json, wins, updated_at)
|
||||||
|
VALUES (?, ?, 1, datetime('now'))
|
||||||
|
ON CONFLICT(phenotype) DO UPDATE SET
|
||||||
|
tier_order_json = excluded.tier_order_json,
|
||||||
|
wins = wins + 1,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
phenotype, string(b))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteTier(order []int, tier int) []int {
|
||||||
|
out := []int{tier}
|
||||||
|
for _, t := range order {
|
||||||
|
if t != tier {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range defaultOrderInts() {
|
||||||
|
found := false
|
||||||
|
for _, x := range out {
|
||||||
|
if x == t {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdaptiveOrder returns learned tier order or defaults.
|
||||||
|
func (s *Store) GetAdaptiveOrder(ctx context.Context, phenotype string) ([]int, error) {
|
||||||
|
if phenotype != "" {
|
||||||
|
var raw string
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT tier_order_json FROM phenotype_tier_orders WHERE phenotype = ?`, phenotype).
|
||||||
|
Scan(&raw)
|
||||||
|
if err == nil && raw != "" && raw != "[]" {
|
||||||
|
var order []int
|
||||||
|
if json.Unmarshal([]byte(raw), &order) == nil && len(order) > 0 {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultOrderInts(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultOrderInts() []int {
|
||||||
|
order := DefaultTierOrder()
|
||||||
|
out := make([]int, len(order))
|
||||||
|
for i := range order {
|
||||||
|
out[i] = i + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHostPhenotype persists recon-derived fingerprint on a host.
|
||||||
|
func (s *Store) SetHostPhenotype(ctx context.Context, hostID, reconJSON, phenotype string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE hosts SET phenotype = ?, fingerprint = COALESCE(NULLIF(fingerprint,''), ?),
|
||||||
|
updated_at = datetime('now') WHERE id = ?`,
|
||||||
|
phenotype, reconJSON, hostID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostHashrate returns current hashrate for earn-before-burn gate.
|
||||||
|
func (s *Store) HostHashrate(ctx context.Context, hostID string) (float64, error) {
|
||||||
|
var hr float64
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT hashrate FROM hosts WHERE id = ?`, hostID).Scan(&hr)
|
||||||
|
return hr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiblingHosts lists other enrolled hosts sharing a phenotype.
|
||||||
|
func (s *Store) SiblingHosts(ctx context.Context, hostID, phenotype string) ([]string, error) {
|
||||||
|
if phenotype == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id FROM hosts WHERE phenotype = ? AND id != ? AND status != 'offline'`,
|
||||||
|
phenotype, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHexID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
91
internal/fleet/phenotype_test.go
Normal file
91
internal/fleet/phenotype_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge-mesh/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPhenotypeFromRecon(t *testing.T) {
|
||||||
|
report := &ReconReport{
|
||||||
|
Kernel: "6.8.12-generic",
|
||||||
|
Arch: "x86_64",
|
||||||
|
Virt: "baremetal",
|
||||||
|
ContainerRuntime: "podman",
|
||||||
|
GPU: GPUInfo{Available: true, Vendor: "nvidia"},
|
||||||
|
}
|
||||||
|
key := PhenotypeFromRecon(report)
|
||||||
|
if key == "" {
|
||||||
|
t.Fatal("expected non-empty phenotype key")
|
||||||
|
}
|
||||||
|
if want := "nvidia"; !containsPart(key, want) {
|
||||||
|
t.Fatalf("expected gpu vendor in key %q", key)
|
||||||
|
}
|
||||||
|
if want := "podman"; !containsPart(key, want) {
|
||||||
|
t.Fatalf("expected runtime in key %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable for same inputs
|
||||||
|
key2 := PhenotypeFromRecon(report)
|
||||||
|
if key != key2 {
|
||||||
|
t.Fatalf("phenotype not stable: %q vs %q", key, key2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptiveOrderAndAtlas(t *testing.T) {
|
||||||
|
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
store := NewStore(conn)
|
||||||
|
ctx := context.Background()
|
||||||
|
pheno := "6.8|x86_64|baremetal|nvidia|podman"
|
||||||
|
|
||||||
|
if err := store.RecordFailure(ctx, pheno, 4); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.RecordFailure(ctx, pheno, 4); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skip, err := store.ShouldSkipTier(ctx, pheno, 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
t.Fatal("should not skip before threshold")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RecordFailure(ctx, pheno, 4); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skip, _ = store.ShouldSkipTier(ctx, pheno, 4)
|
||||||
|
if !skip {
|
||||||
|
t.Fatal("expected atlas skip after 3 failures")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RecordWin(ctx, pheno, 7); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
order, err := store.GetAdaptiveOrder(ctx, pheno)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(order) != 14 {
|
||||||
|
t.Fatalf("expected 14 tiers, got %d", len(order))
|
||||||
|
}
|
||||||
|
if order[0] != 7 {
|
||||||
|
t.Fatalf("winning tier 7 should be first, got %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPart(s, part string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(part); i++ {
|
||||||
|
if s[i:i+len(part)] == part {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
91
internal/fleet/policy_snapshot.go
Normal file
91
internal/fleet/policy_snapshot.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PolicySnapshot is a shareable frozen policy bundle.
|
||||||
|
type PolicySnapshot struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
PolicyJSON json.RawMessage `json:"policy_json"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePolicySnapshot stores a policy JSON blob and returns an opaque token.
|
||||||
|
func (s *Store) CreatePolicySnapshot(policy map[string]any, ttl time.Duration) (*PolicySnapshot, error) {
|
||||||
|
raw, err := json.Marshal(policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal policy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := randomToken(16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expires := ""
|
||||||
|
if ttl > 0 {
|
||||||
|
expires = now.Add(ttl).Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO policy_snapshots (token, policy_json, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, token, string(raw), nullIfEmpty(expires), now.Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert policy snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PolicySnapshot{
|
||||||
|
Token: token,
|
||||||
|
PolicyJSON: raw,
|
||||||
|
ExpiresAt: expires,
|
||||||
|
CreatedAt: now.Format(time.RFC3339),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPolicySnapshot loads a snapshot by token (public summon link).
|
||||||
|
func (s *Store) GetPolicySnapshot(token string) (*PolicySnapshot, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT token, policy_json, expires_at, created_at
|
||||||
|
FROM policy_snapshots WHERE token = ?
|
||||||
|
`, token)
|
||||||
|
|
||||||
|
var snap PolicySnapshot
|
||||||
|
var expires sql.NullString
|
||||||
|
var created string
|
||||||
|
if err := row.Scan(&snap.Token, &snap.PolicyJSON, &expires, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if expires.Valid {
|
||||||
|
snap.ExpiresAt = expires.String
|
||||||
|
t, err := time.Parse(time.RFC3339, expires.String)
|
||||||
|
if err == nil && time.Now().After(t) {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap.CreatedAt = created
|
||||||
|
return &snap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) sql.NullString {
|
||||||
|
if s == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
151
internal/fleet/recon.go
Normal file
151
internal/fleet/recon.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReconReport holds read-only host capability probes for triple-onion deploy.
|
||||||
|
type ReconReport struct {
|
||||||
|
Kernel string `json:"kernel"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
CgroupsV2 bool `json:"cgroups_v2"`
|
||||||
|
PodmanAvailable bool `json:"podman_available"`
|
||||||
|
DockerAvailable bool `json:"docker_available"`
|
||||||
|
GPU GPUInfo `json:"gpu"`
|
||||||
|
Virt string `json:"virt,omitempty"`
|
||||||
|
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GPUInfo struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Devices []string `json:"devices,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRecon executes read-only probes: kernel, cgroups, podman, GPU.
|
||||||
|
func RunRecon() (*ReconReport, error) {
|
||||||
|
report := &ReconReport{Extra: map[string]string{}}
|
||||||
|
|
||||||
|
if out, err := exec.Command("uname", "-r").Output(); err == nil {
|
||||||
|
report.Kernel = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||||
|
report.Arch = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
report.CgroupsV2 = probeCgroupsV2()
|
||||||
|
report.PodmanAvailable = commandExists("podman")
|
||||||
|
report.DockerAvailable = commandExists("docker")
|
||||||
|
report.GPU = probeGPU()
|
||||||
|
report.Virt = probeVirt()
|
||||||
|
report.ContainerRuntime = detectContainerRuntime(report)
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeCgroupsV2() bool {
|
||||||
|
data, err := os.ReadFile("/sys/fs/cgroup/cgroup.controllers")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(strings.TrimSpace(string(data))) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeGPU() GPUInfo {
|
||||||
|
info := GPUInfo{}
|
||||||
|
if commandExists("nvidia-smi") {
|
||||||
|
if out, err := exec.Command("nvidia-smi", "-L").Output(); err == nil {
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if line != "" {
|
||||||
|
info.Devices = append(info.Devices, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(info.Devices) > 0 {
|
||||||
|
info.Available = true
|
||||||
|
info.Vendor = "nvidia"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !info.Available && commandExists("rocm-smi") {
|
||||||
|
if _, err := exec.Command("rocm-smi", "--showid").Output(); err == nil {
|
||||||
|
info.Available = true
|
||||||
|
info.Vendor = "amd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeVirt() string {
|
||||||
|
data, err := os.ReadFile("/sys/class/dmi/id/product_name")
|
||||||
|
if err != nil {
|
||||||
|
return "baremetal"
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSpace(string(data)))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(name, "vmware"), strings.Contains(name, "virtualbox"),
|
||||||
|
strings.Contains(name, "kvm"), strings.Contains(name, "qemu"):
|
||||||
|
return "vm"
|
||||||
|
default:
|
||||||
|
return "baremetal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectContainerRuntime(r *ReconReport) string {
|
||||||
|
if r.PodmanAvailable {
|
||||||
|
return "podman"
|
||||||
|
}
|
||||||
|
if r.DockerAvailable {
|
||||||
|
return "docker"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhenotypeFromRecon builds a stable fingerprint key from recon data.
|
||||||
|
func PhenotypeFromRecon(r *ReconReport) string {
|
||||||
|
gpu := "none"
|
||||||
|
if r.GPU.Available {
|
||||||
|
gpu = r.GPU.Vendor
|
||||||
|
}
|
||||||
|
rt := r.ContainerRuntime
|
||||||
|
if rt == "" {
|
||||||
|
rt = "none"
|
||||||
|
}
|
||||||
|
parts := []string{r.Arch, r.Virt, gpu, rt}
|
||||||
|
key := strings.Join(parts, "|")
|
||||||
|
// Normalize kernel major for grouping
|
||||||
|
if idx := strings.Index(r.Kernel, "."); idx > 0 {
|
||||||
|
key = r.Kernel[:idx] + "." + strings.Split(r.Kernel[idx+1:], ".")[0] + "|" + key
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconJSON serializes a recon report for lotl metadata.
|
||||||
|
func ReconJSON(r *ReconReport) string {
|
||||||
|
b, _ := json.Marshal(r)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseReconReport loads recon from JSON metadata.
|
||||||
|
func ParseReconReport(raw string) (*ReconReport, error) {
|
||||||
|
var r ReconReport
|
||||||
|
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostReconPath returns optional cached recon file for a host.
|
||||||
|
func HostReconPath(dataDir, hostID string) string {
|
||||||
|
return filepath.Join(dataDir, "recon", hostID+".json")
|
||||||
|
}
|
||||||
219
internal/fleet/store.go
Normal file
219
internal/fleet/store.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store persists fleet host and mining profile state.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpsertHeartbeat(hb types.HeartbeatPayload) (*types.Host, error) {
|
||||||
|
hostID := hb.HostID
|
||||||
|
if hostID == "" {
|
||||||
|
hostID = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
hps := hb.EffectiveHashrate()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO hosts (
|
||||||
|
id, hostname, fingerprint, status, hashrate, hashrate_hps,
|
||||||
|
current_tier, tier_type, tier_state, last_seen_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, 'online', ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
hostname = excluded.hostname,
|
||||||
|
fingerprint = COALESCE(NULLIF(excluded.fingerprint, ''), hosts.fingerprint),
|
||||||
|
status = 'online',
|
||||||
|
hashrate = excluded.hashrate,
|
||||||
|
hashrate_hps = excluded.hashrate_hps,
|
||||||
|
current_tier = excluded.current_tier,
|
||||||
|
tier_type = excluded.tier_type,
|
||||||
|
tier_state = excluded.tier_state,
|
||||||
|
last_seen_at = excluded.last_seen_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, hostID, hb.Hostname, hb.Fingerprint, hps, hps,
|
||||||
|
hb.CurrentTier, hb.TierType, hb.TierState, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("upsert host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetHost(hostID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetHost(id string) (*types.Host, error) {
|
||||||
|
row := s.db.QueryRow(`
|
||||||
|
SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
||||||
|
current_tier, tier_type, tier_state, clearance_level,
|
||||||
|
mining_profile_id, last_seen_at, created_at, updated_at
|
||||||
|
FROM hosts WHERE id = ?
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
return scanHost(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListHosts() ([]types.Host, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
||||||
|
current_tier, tier_type, tier_state, clearance_level,
|
||||||
|
mining_profile_id, last_seen_at, created_at, updated_at
|
||||||
|
FROM hosts ORDER BY updated_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list hosts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var hosts []types.Host
|
||||||
|
for rows.Next() {
|
||||||
|
h, err := scanHost(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hosts = append(hosts, *h)
|
||||||
|
}
|
||||||
|
return hosts, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanHost(scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (*types.Host, error) {
|
||||||
|
var h types.Host
|
||||||
|
var fp, pheno, tierType, tierState, mpID, lastSeen sql.NullString
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
|
||||||
|
err := scanner.Scan(
|
||||||
|
&h.ID, &h.Hostname, &fp, &pheno, &h.Status, &h.Hashrate, &h.HashrateHps,
|
||||||
|
&h.CurrentTier, &tierType, &tierState, &h.ClearanceLevel,
|
||||||
|
&mpID, &lastSeen, &createdAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan host: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fp.Valid {
|
||||||
|
h.Fingerprint = fp.String
|
||||||
|
}
|
||||||
|
if pheno.Valid {
|
||||||
|
h.Phenotype = pheno.String
|
||||||
|
}
|
||||||
|
if tierType.Valid {
|
||||||
|
h.TierType = tierType.String
|
||||||
|
}
|
||||||
|
if tierState.Valid {
|
||||||
|
h.TierState = tierState.String
|
||||||
|
}
|
||||||
|
if mpID.Valid {
|
||||||
|
h.MiningProfileID = &mpID.String
|
||||||
|
}
|
||||||
|
if lastSeen.Valid {
|
||||||
|
t, _ := time.Parse(time.RFC3339, lastSeen.String)
|
||||||
|
h.LastSeenAt = &t
|
||||||
|
}
|
||||||
|
h.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
h.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
return &h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkOffline(id string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.db.Exec(`UPDATE hosts SET status = 'offline', updated_at = ? WHERE id = ?`, now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveMiningProfile(ctx context.Context, profile *types.MiningProfile) error {
|
||||||
|
tiersJSON, err := json.Marshal(profile.Tiers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal tiers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy := 0
|
||||||
|
if profile.PolicyFromServer {
|
||||||
|
policy = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile.CreatedAt.IsZero() {
|
||||||
|
profile.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
profile.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
_, err = s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO mining_profiles (id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
wallet_address = excluded.wallet_address,
|
||||||
|
tiers_json = excluded.tiers_json,
|
||||||
|
policy_from_server = excluded.policy_from_server,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`, profile.ID, profile.Name, profile.WalletAddress, string(tiersJSON), policy,
|
||||||
|
profile.CreatedAt.UTC().Format(time.RFC3339), profile.UpdatedAt.UTC().Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetMiningProfile(ctx context.Context, id string) (*types.MiningProfile, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, wallet_address, tiers_json, policy_from_server, created_at, updated_at
|
||||||
|
FROM mining_profiles WHERE id = ?
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
var p types.MiningProfile
|
||||||
|
var tiersJSON, created, updated sql.NullString
|
||||||
|
var policy int
|
||||||
|
|
||||||
|
if err := row.Scan(&p.ID, &p.Name, &p.WalletAddress, &tiersJSON, &policy, &created, &updated); err != nil {
|
||||||
|
return nil, fmt.Errorf("get mining profile: %w", err)
|
||||||
|
}
|
||||||
|
if tiersJSON.Valid {
|
||||||
|
_ = json.Unmarshal([]byte(tiersJSON.String), &p.Tiers)
|
||||||
|
}
|
||||||
|
p.PolicyFromServer = policy == 1
|
||||||
|
if created.Valid {
|
||||||
|
p.CreatedAt, _ = time.Parse(time.RFC3339, created.String)
|
||||||
|
}
|
||||||
|
if updated.Valid {
|
||||||
|
p.UpdatedAt, _ = time.Parse(time.RFC3339, updated.String)
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AssignMiningProfile(ctx context.Context, hostID, profileID string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
res, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE hosts SET mining_profile_id = ?, updated_at = ? WHERE id = ?
|
||||||
|
`, profileID, now, hostID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetHostMiningProfile(ctx context.Context, hostID string) (*types.MiningProfile, error) {
|
||||||
|
host, err := s.GetHost(hostID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if host.MiningProfileID == nil || *host.MiningProfileID == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return s.GetMiningProfile(ctx, *host.MiningProfileID)
|
||||||
|
}
|
||||||
96
internal/fleet/store_test.go
Normal file
96
internal/fleet/store_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
"forge-mesh/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreUpsertHeartbeat(t *testing.T) {
|
||||||
|
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
store := NewStore(conn)
|
||||||
|
host, err := store.UpsertHeartbeat(types.HeartbeatPayload{
|
||||||
|
Hostname: "test-host",
|
||||||
|
HashrateHps: 1234.5,
|
||||||
|
CurrentTier: 2,
|
||||||
|
TierType: "xmrig",
|
||||||
|
TierState: "active",
|
||||||
|
Arch: "amd64",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if host.ID == "" {
|
||||||
|
t.Fatal("expected host id")
|
||||||
|
}
|
||||||
|
if host.HashrateHps != 1234.5 {
|
||||||
|
t.Fatalf("hashrate_hps: got %v", host.HashrateHps)
|
||||||
|
}
|
||||||
|
if host.CurrentTier != 2 {
|
||||||
|
t.Fatalf("current_tier: got %d", host.CurrentTier)
|
||||||
|
}
|
||||||
|
|
||||||
|
host2, err := store.UpsertHeartbeat(types.HeartbeatPayload{
|
||||||
|
HostID: host.ID,
|
||||||
|
Hostname: "test-host",
|
||||||
|
HashrateHps: 5000,
|
||||||
|
CurrentTier: 3,
|
||||||
|
TierType: "gpu",
|
||||||
|
TierState: "probing",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if host2.HashrateHps != 5000 {
|
||||||
|
t.Fatalf("hashrate: got %v", host2.HashrateHps)
|
||||||
|
}
|
||||||
|
if host2.CurrentTier != 3 {
|
||||||
|
t.Fatalf("tier: got %d", host2.CurrentTier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMiningProfileRoundTrip(t *testing.T) {
|
||||||
|
conn, err := db.Open(t.TempDir() + "/test.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
store := NewStore(conn)
|
||||||
|
host, err := store.UpsertHeartbeat(types.HeartbeatPayload{Hostname: "miner-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := &types.MiningProfile{
|
||||||
|
ID: "prof-1",
|
||||||
|
Name: "test",
|
||||||
|
WalletAddress: "wallet-pin-xyz",
|
||||||
|
Tiers: []types.MiningTierSpec{
|
||||||
|
{Type: "xmrig", Duration: 5},
|
||||||
|
},
|
||||||
|
PolicyFromServer: true,
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.SaveMiningProfile(ctx, profile); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.AssignMiningProfile(ctx, host.ID, profile.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.GetHostMiningProfile(ctx, host.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.WalletAddress != "wallet-pin-xyz" {
|
||||||
|
t.Fatalf("wallet: %q", got.WalletAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
246
internal/fleet/subnet.go
Normal file
246
internal/fleet/subnet.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const immuneFailureThreshold = 5
|
||||||
|
const immunePauseDuration = 24 * time.Hour
|
||||||
|
|
||||||
|
// SubnetMapper sweeps operator-declared CIDRs with /24 immune pause after failures.
|
||||||
|
type SubnetMapper struct {
|
||||||
|
Store *Store
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubnetCIDR is an operator-declared scan target.
|
||||||
|
type SubnetCIDR struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCIDR registers a CIDR for incremental discovery.
|
||||||
|
func (m *SubnetMapper) AddCIDR(ctx context.Context, cidr string) (*SubnetCIDR, error) {
|
||||||
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid cidr: %w", err)
|
||||||
|
}
|
||||||
|
id := randomHex(16)
|
||||||
|
_, err := m.Store.DB().ExecContext(ctx, `
|
||||||
|
INSERT INTO subnet_cidrs (id, cidr, enabled) VALUES (?, ?, 1)`, id, cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SubnetCIDR{ID: id, CIDR: cidr, Enabled: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCIDRs returns declared subnets.
|
||||||
|
func (m *SubnetMapper) ListCIDRs(ctx context.Context) ([]SubnetCIDR, error) {
|
||||||
|
rows, err := m.Store.DB().QueryContext(ctx, `
|
||||||
|
SELECT id, cidr, enabled, last_scan_at FROM subnet_cidrs ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []SubnetCIDR
|
||||||
|
for rows.Next() {
|
||||||
|
var s SubnetCIDR
|
||||||
|
var enabled int
|
||||||
|
var lastScan sqlNullTime
|
||||||
|
if err := rows.Scan(&s.ID, &s.CIDR, &enabled, &lastScan); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.Enabled = enabled == 1
|
||||||
|
if lastScan.Valid {
|
||||||
|
s.LastScanAt = &lastScan.Time
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type sqlNullTime struct {
|
||||||
|
Valid bool
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *sqlNullTime) Scan(src interface{}) error {
|
||||||
|
if src == nil {
|
||||||
|
n.Valid = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := src.(type) {
|
||||||
|
case string:
|
||||||
|
if v == "" {
|
||||||
|
n.Valid = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02 15:04:05", v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n.Time = t
|
||||||
|
n.Valid = true
|
||||||
|
case []byte:
|
||||||
|
return n.Scan(string(v))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanResult holds hosts discovered in a sweep.
|
||||||
|
type ScanResult struct {
|
||||||
|
CIDR string `json:"cidr"`
|
||||||
|
Prefix string `json:"prefix_24"`
|
||||||
|
Alive []string `json:"alive"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepCIDR pings hosts in a CIDR unless /24 is immune-paused.
|
||||||
|
func (m *SubnetMapper) SweepCIDR(ctx context.Context, cidr string) (*ScanResult, error) {
|
||||||
|
ip, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix24 := prefixOf24(ip)
|
||||||
|
if paused, reason := m.isImmunePaused(ctx, prefix24); paused {
|
||||||
|
return &ScanResult{CIDR: cidr, Prefix: prefix24, Skipped: true, Reason: reason}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alive, sweepErr := pingSweep(ctx, ipNet)
|
||||||
|
if sweepErr != nil {
|
||||||
|
_ = m.recordSubnetFailure(ctx, prefix24)
|
||||||
|
return &ScanResult{CIDR: cidr, Prefix: prefix24, Alive: alive}, sweepErr
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = m.Store.DB().ExecContext(ctx, `
|
||||||
|
UPDATE subnet_cidrs SET last_scan_at = datetime('now') WHERE cidr = ?`, cidr)
|
||||||
|
|
||||||
|
return &ScanResult{CIDR: cidr, Prefix: prefix24, Alive: alive}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepAll runs enabled CIDRs sequentially.
|
||||||
|
func (m *SubnetMapper) SweepAll(ctx context.Context) ([]ScanResult, error) {
|
||||||
|
cidrs, err := m.ListCIDRs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var results []ScanResult
|
||||||
|
for _, c := range cidrs {
|
||||||
|
if !c.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, err := m.SweepCIDR(ctx, c.CIDR)
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
results = append(results, *r)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixOf24(ip net.IP) string {
|
||||||
|
v4 := ip.To4()
|
||||||
|
if v4 == nil {
|
||||||
|
return ip.String() + "/64"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SubnetMapper) isImmunePaused(ctx context.Context, prefix string) (bool, string) {
|
||||||
|
var count int
|
||||||
|
var pausedUntil sqlNullTime
|
||||||
|
err := m.Store.DB().QueryRowContext(ctx, `
|
||||||
|
SELECT failure_count, paused_until FROM subnet_immune WHERE prefix = ?`, prefix).
|
||||||
|
Scan(&count, &pausedUntil)
|
||||||
|
if err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if pausedUntil.Valid && time.Now().Before(pausedUntil.Time) {
|
||||||
|
return true, fmt.Sprintf("/24 immune pause until %s", pausedUntil.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SubnetMapper) recordSubnetFailure(ctx context.Context, prefix string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
_ = m.Store.DB().QueryRowContext(ctx, `SELECT failure_count FROM subnet_immune WHERE prefix = ?`, prefix).Scan(&count)
|
||||||
|
count++
|
||||||
|
|
||||||
|
pausedUntil := ""
|
||||||
|
if count >= immuneFailureThreshold {
|
||||||
|
until := time.Now().Add(immunePauseDuration)
|
||||||
|
pausedUntil = until.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := m.Store.DB().ExecContext(ctx, `
|
||||||
|
INSERT INTO subnet_immune (prefix, failure_count, paused_until, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(prefix) DO UPDATE SET
|
||||||
|
failure_count = excluded.failure_count,
|
||||||
|
paused_until = excluded.paused_until,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
prefix, count, nullStringOrNil(pausedUntil))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullStringOrNil(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func pingSweep(ctx context.Context, ipNet *net.IPNet) ([]string, error) {
|
||||||
|
var alive []string
|
||||||
|
ip := ipNet.IP.Mask(ipNet.Mask)
|
||||||
|
for ip := incrementIP(ip); ipNet.Contains(ip); ip = incrementIP(ip) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return alive, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if pingHost(ip.String()) {
|
||||||
|
alive = append(alive, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pingHost(host string) bool {
|
||||||
|
out, err := exec.Command("ping", "-c", "1", "-W", "1", host).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(out), "1 received") || strings.Contains(string(out), "1 packets received")
|
||||||
|
}
|
||||||
|
|
||||||
|
func incrementIP(ip net.IP) net.IP {
|
||||||
|
ip = ip.To16()
|
||||||
|
for i := len(ip) - 1; i >= 0; i-- {
|
||||||
|
ip[i]++
|
||||||
|
if ip[i] != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
181
internal/fleet/summary.go
Normal file
181
internal/fleet/summary.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
"forge-mesh/internal/policy"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FleetSummary aggregates fleet stats for the dashboard API.
|
||||||
|
type FleetSummary struct {
|
||||||
|
Hosts []FleetHostCard `json:"hosts"`
|
||||||
|
TotalHashrate float64 `json:"totalHashrate"`
|
||||||
|
OnlineCount int `json:"onlineCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FleetHostCard is the dashboard-facing host shape (matches React types).
|
||||||
|
type FleetHostCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Hashrate float64 `json:"hashrate"`
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
TierName string `json:"tierName"`
|
||||||
|
TierState string `json:"tierState"`
|
||||||
|
Algo string `json:"algo"`
|
||||||
|
UptimeSec int `json:"uptimeSec"`
|
||||||
|
LastSeen string `json:"lastSeen"`
|
||||||
|
Clearance int `json:"clearance"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToFleetCard converts a DB host to a dashboard card.
|
||||||
|
func ToFleetCard(h *types.Host) FleetHostCard {
|
||||||
|
if h == nil {
|
||||||
|
return FleetHostCard{}
|
||||||
|
}
|
||||||
|
|
||||||
|
hps := h.HashrateHps
|
||||||
|
if hps == 0 {
|
||||||
|
hps = h.Hashrate
|
||||||
|
}
|
||||||
|
|
||||||
|
online := h.Status == "online" || h.Status == "mining" || h.Status == "paused"
|
||||||
|
tier := h.CurrentTier
|
||||||
|
if tier == 0 {
|
||||||
|
tier = 2
|
||||||
|
}
|
||||||
|
tierName := policy.TierDisplayName(h.TierType)
|
||||||
|
if tierName == "" || tierName == h.TierType {
|
||||||
|
tierName = "Bundled xmrig"
|
||||||
|
}
|
||||||
|
tierState := h.TierState
|
||||||
|
if tierState == "" {
|
||||||
|
switch h.Status {
|
||||||
|
case "mining":
|
||||||
|
tierState = "active"
|
||||||
|
case "probing":
|
||||||
|
tierState = "probing"
|
||||||
|
case "paused":
|
||||||
|
tierState = "paused"
|
||||||
|
case "offline":
|
||||||
|
tierState = "idle"
|
||||||
|
online = false
|
||||||
|
default:
|
||||||
|
tierState = "idle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
arch := h.Phenotype
|
||||||
|
if arch == "" {
|
||||||
|
arch = "linux/amd64"
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeen := ""
|
||||||
|
if h.LastSeenAt != nil {
|
||||||
|
lastSeen = h.LastSeenAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FleetHostCard{
|
||||||
|
ID: h.ID,
|
||||||
|
Hostname: h.Hostname,
|
||||||
|
IP: coalesceIP(h.Fingerprint),
|
||||||
|
Arch: arch,
|
||||||
|
Hashrate: hps,
|
||||||
|
Tier: tier,
|
||||||
|
TierName: tierName,
|
||||||
|
TierState: tierState,
|
||||||
|
Algo: "rx/0",
|
||||||
|
UptimeSec: 0,
|
||||||
|
LastSeen: lastSeen,
|
||||||
|
Clearance: h.ClearanceLevel,
|
||||||
|
Online: online,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesceIP(fp string) string {
|
||||||
|
if fp == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFleetSummary builds the GET /api/v1/fleet response.
|
||||||
|
func (s *Store) BuildFleetSummary() (FleetSummary, error) {
|
||||||
|
hosts, err := s.ListHosts()
|
||||||
|
if err != nil {
|
||||||
|
return FleetSummary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := FleetSummary{Hosts: make([]FleetHostCard, 0, len(hosts))}
|
||||||
|
for i := range hosts {
|
||||||
|
card := ToFleetCard(&hosts[i])
|
||||||
|
summary.Hosts = append(summary.Hosts, card)
|
||||||
|
if card.Online {
|
||||||
|
summary.OnlineCount++
|
||||||
|
summary.TotalHashrate += card.Hashrate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedDemoHost inserts a demo host when fleet is empty.
|
||||||
|
func (s *Store) SeedDemoHost() error {
|
||||||
|
hosts, err := s.ListHosts()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(hosts) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := uuid.NewString()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
INSERT INTO hosts (id, hostname, fingerprint, phenotype, status, hashrate, hashrate_hps,
|
||||||
|
current_tier, tier_type, tier_state, clearance_level, last_seen_at, created_at, updated_at)
|
||||||
|
VALUES (?, 'forge-node-alpha', '10.0.1.12', 'linux/amd64', 'mining', 18200000, 18200000,
|
||||||
|
2, 'xmrig', 'active', 2, ?, ?, ?)`,
|
||||||
|
id, now, now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchHost enrolls or refreshes a host from the register API.
|
||||||
|
func (s *Store) TouchHost(hostname, fingerprint, arch string) (*types.Host, error) {
|
||||||
|
phenotype := ""
|
||||||
|
if arch != "" {
|
||||||
|
phenotype = "linux/" + arch
|
||||||
|
}
|
||||||
|
hb := types.HeartbeatPayload{
|
||||||
|
Hostname: hostname,
|
||||||
|
Fingerprint: fingerprint,
|
||||||
|
Arch: arch,
|
||||||
|
TierState: "idle",
|
||||||
|
}
|
||||||
|
hb.SetHashrateFields(0)
|
||||||
|
host, err := s.UpsertHeartbeat(hb)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if phenotype != "" {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, _ = s.db.Exec(`UPDATE hosts SET phenotype = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
phenotype, now, host.ID)
|
||||||
|
return s.GetHost(host.ID)
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHostStatus updates host status.
|
||||||
|
func (s *Store) SetHostStatus(id, status string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.db.Exec(`UPDATE hosts SET status = ?, updated_at = ? WHERE id = ?`, status, now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound indicates a missing host.
|
||||||
|
var ErrNotFound = fmt.Errorf("host not found")
|
||||||
189
internal/fleet/tiers.go
Normal file
189
internal/fleet/tiers.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package fleet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TierType identifies one of 14 Linux deploy mechanisms.
|
||||||
|
type TierType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierSSHKey TierType = "ssh_key"
|
||||||
|
TierCurlBash TierType = "curl_bash"
|
||||||
|
TierAnsiblePull TierType = "ansible_pull"
|
||||||
|
TierPodmanRootless TierType = "podman_rootless"
|
||||||
|
TierSystemdTransient TierType = "systemd_transient"
|
||||||
|
TierSnapFlatpak TierType = "snap_flatpak"
|
||||||
|
TierLANCachePeer TierType = "lan_cache_peer"
|
||||||
|
TierDNSTXT TierType = "dns_txt"
|
||||||
|
TierMTLSWireGuard TierType = "mtls_wireguard"
|
||||||
|
TierImmutableOCI TierType = "immutable_oci"
|
||||||
|
TierNixFlake TierType = "nix_flake"
|
||||||
|
TierErasureReasm TierType = "erasure_reassembly"
|
||||||
|
TierFleetTorrent TierType = "fleet_torrent"
|
||||||
|
TierOfflineBundle TierType = "offline_contingency"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTierOrder returns the canonical 14-tier Linux deploy sequence.
|
||||||
|
func DefaultTierOrder() []TierType {
|
||||||
|
return []TierType{
|
||||||
|
TierSSHKey,
|
||||||
|
TierCurlBash,
|
||||||
|
TierAnsiblePull,
|
||||||
|
TierPodmanRootless,
|
||||||
|
TierSystemdTransient,
|
||||||
|
TierSnapFlatpak,
|
||||||
|
TierLANCachePeer,
|
||||||
|
TierDNSTXT,
|
||||||
|
TierMTLSWireGuard,
|
||||||
|
TierImmutableOCI,
|
||||||
|
TierNixFlake,
|
||||||
|
TierErasureReasm,
|
||||||
|
TierFleetTorrent,
|
||||||
|
TierOfflineBundle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierSlot maps 1-based tier index to type.
|
||||||
|
func TierSlot(n int) (TierType, error) {
|
||||||
|
order := DefaultTierOrder()
|
||||||
|
if n < 1 || n > len(order) {
|
||||||
|
return "", fmt.Errorf("tier %d out of range 1-%d", n, len(order))
|
||||||
|
}
|
||||||
|
return order[n-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierExecutor runs deploy phases for a single tier.
|
||||||
|
type TierExecutor struct {
|
||||||
|
Store *Store
|
||||||
|
HostID string
|
||||||
|
Report *ReconReport
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierResult captures outcome of a tier attempt.
|
||||||
|
type TierResult struct {
|
||||||
|
Tier int `json:"tier"`
|
||||||
|
Type TierType `json:"type"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Elapsed time.Duration `json:"elapsed_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTier runs recon → patch_first gate → deploy for one tier slot.
|
||||||
|
func (e *TierExecutor) ExecuteTier(ctx context.Context, tierNum int) (*TierResult, error) {
|
||||||
|
tierType, err := TierSlot(tierNum)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
result := &TierResult{Tier: tierNum, Type: tierType, Phase: "recon", Status: "running"}
|
||||||
|
|
||||||
|
if e.Store != nil && e.Report != nil {
|
||||||
|
phenotype := PhenotypeFromRecon(e.Report)
|
||||||
|
if skip, _ := e.Store.ShouldSkipTier(ctx, phenotype, tierNum); skip {
|
||||||
|
result.Phase = "atlas_skip"
|
||||||
|
result.Status = "skipped"
|
||||||
|
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "atlas_skip", "skipped", "failure atlas immune", ReconJSON(e.Report))
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recon phase (read-only, already done at executor init)
|
||||||
|
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "recon", "success", "", ReconJSON(e.Report))
|
||||||
|
|
||||||
|
// Patch-first gate
|
||||||
|
result.Phase = "patch_first"
|
||||||
|
if !e.passPatchGate(tierType) {
|
||||||
|
result.Status = "skipped"
|
||||||
|
result.Error = "patch_first gate failed"
|
||||||
|
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "patch_first", "skipped", result.Error, "")
|
||||||
|
_ = e.Store.RecordFailure(ctx, PhenotypeFromRecon(e.Report), tierNum)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "patch_first", "success", "", "")
|
||||||
|
|
||||||
|
// Deploy phase (simulated success paths per tier capability)
|
||||||
|
result.Phase = "deploy"
|
||||||
|
deployErr := e.deploy(ctx, tierType)
|
||||||
|
if deployErr != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Error = deployErr.Error()
|
||||||
|
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "deploy", "failed", result.Error, "")
|
||||||
|
_ = e.Store.RecordFailure(ctx, PhenotypeFromRecon(e.Report), tierNum)
|
||||||
|
} else {
|
||||||
|
result.Status = "success"
|
||||||
|
_ = e.Store.LogLOTL(ctx, e.HostID, tierNum, "deploy", "success", "", "")
|
||||||
|
_ = e.Store.RecordWin(ctx, PhenotypeFromRecon(e.Report), tierNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Elapsed = time.Since(start)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TierExecutor) passPatchGate(t TierType) bool {
|
||||||
|
if e.Report == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch t {
|
||||||
|
case TierPodmanRootless, TierImmutableOCI:
|
||||||
|
return e.Report.PodmanAvailable || e.Report.DockerAvailable
|
||||||
|
case TierNixFlake:
|
||||||
|
return commandExists("nix")
|
||||||
|
case TierMTLSWireGuard:
|
||||||
|
return commandExists("wg")
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TierExecutor) deploy(ctx context.Context, t TierType) error {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t {
|
||||||
|
case TierPodmanRootless:
|
||||||
|
if e.Report != nil && !e.Report.PodmanAvailable {
|
||||||
|
return fmt.Errorf("podman not available")
|
||||||
|
}
|
||||||
|
case TierImmutableOCI:
|
||||||
|
if e.Report == nil || (!e.Report.PodmanAvailable && !e.Report.DockerAvailable) {
|
||||||
|
return fmt.Errorf("no container runtime")
|
||||||
|
}
|
||||||
|
case TierNixFlake:
|
||||||
|
if !commandExists("nix") {
|
||||||
|
return fmt.Errorf("nix not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Authorized deploy tiers succeed when gates pass; actual spawn is agent-side.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunTierChain executes tiers in order until one succeeds or all fail.
|
||||||
|
func RunTierChain(ctx context.Context, store *Store, hostID string, order []int) ([]*TierResult, error) {
|
||||||
|
report, err := RunRecon()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
exec := &TierExecutor{Store: store, HostID: hostID, Report: report}
|
||||||
|
var results []*TierResult
|
||||||
|
|
||||||
|
for _, tierNum := range order {
|
||||||
|
res, err := exec.ExecuteTier(ctx, tierNum)
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
results = append(results, res)
|
||||||
|
if res.Status == "success" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
186
internal/forge/build.go
Normal file
186
internal/forge/build.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package forge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var targets = []struct {
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
}{
|
||||||
|
{"linux", "amd64"},
|
||||||
|
{"linux", "arm64"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline builds and optionally signs agent binaries.
|
||||||
|
type Pipeline struct {
|
||||||
|
db *sql.DB
|
||||||
|
artifactsDir string
|
||||||
|
signingKey *KeyPair
|
||||||
|
agentMainPath string
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPipeline(db *sql.DB, artifactsDir, signingKeyPath, agentMainPath, version string) (*Pipeline, error) {
|
||||||
|
key, err := LoadOrCreateKey(signingKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Pipeline{
|
||||||
|
db: db,
|
||||||
|
artifactsDir: artifactsDir,
|
||||||
|
signingKey: key,
|
||||||
|
agentMainPath: agentMainPath,
|
||||||
|
version: version,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAll cross-compiles agent for linux amd64/arm64, signs, and stores artifacts.
|
||||||
|
func (p *Pipeline) BuildAll(public bool) ([]types.Build, error) {
|
||||||
|
var builds []types.Build
|
||||||
|
for _, tgt := range targets {
|
||||||
|
b, err := p.buildOne(tgt.OS, tgt.Arch, public)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
builds = append(builds, *b)
|
||||||
|
}
|
||||||
|
return builds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) buildOne(osName, arch string, public bool) (*types.Build, error) {
|
||||||
|
outName := fmt.Sprintf("forge-mesh-agent-%s-%s", osName, arch)
|
||||||
|
outPath := filepath.Join(p.artifactsDir, outName)
|
||||||
|
|
||||||
|
cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w",
|
||||||
|
"-o", outPath,
|
||||||
|
p.agentMainPath,
|
||||||
|
)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GOOS="+osName,
|
||||||
|
"GOARCH="+arch,
|
||||||
|
"CGO_ENABLED=0",
|
||||||
|
)
|
||||||
|
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return nil, fmt.Errorf("build %s/%s: %w\n%s", osName, arch, err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
checksum := hex.EncodeToString(sum[:])
|
||||||
|
sig := p.signingKey.Sign(data)
|
||||||
|
|
||||||
|
build := types.Build{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
OS: osName,
|
||||||
|
Arch: arch,
|
||||||
|
Version: p.version,
|
||||||
|
Checksum: checksum,
|
||||||
|
Signature: sig,
|
||||||
|
Public: public,
|
||||||
|
Path: outPath,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.saveBuild(&build); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &build, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pipeline) saveBuild(b *types.Build) error {
|
||||||
|
pub := 0
|
||||||
|
if b.Public {
|
||||||
|
pub = 1
|
||||||
|
}
|
||||||
|
_, err := p.db.Exec(`
|
||||||
|
INSERT INTO builds (id, os, arch, version, checksum, signature, public, path, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, b.ID, b.OS, b.Arch, b.Version, b.Checksum, b.Signature, pub, b.Path, b.CreatedAt.Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestPublic returns the newest public build for os/arch.
|
||||||
|
func LatestPublic(db *sql.DB, osName, arch string) (*types.Build, error) {
|
||||||
|
row := db.QueryRow(`
|
||||||
|
SELECT id, os, arch, version, checksum, signature, public, path, created_at
|
||||||
|
FROM builds
|
||||||
|
WHERE public = 1 AND os = ? AND arch = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, osName, arch)
|
||||||
|
|
||||||
|
var b types.Build
|
||||||
|
var pub int
|
||||||
|
var path sql.NullString
|
||||||
|
var createdAt string
|
||||||
|
|
||||||
|
err := row.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &b.Signature, &pub, &path, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.Public = pub == 1
|
||||||
|
if path.Valid {
|
||||||
|
b.Path = path.String
|
||||||
|
}
|
||||||
|
b.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuild loads a build by ID.
|
||||||
|
func GetBuild(db *sql.DB, id string) (*types.Build, error) {
|
||||||
|
row := db.QueryRow(`
|
||||||
|
SELECT id, os, arch, version, checksum, signature, public, path, created_at
|
||||||
|
FROM builds WHERE id = ?
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
var b types.Build
|
||||||
|
var pub int
|
||||||
|
var path sql.NullString
|
||||||
|
var createdAt string
|
||||||
|
|
||||||
|
err := row.Scan(&b.ID, &b.OS, &b.Arch, &b.Version, &b.Checksum, &b.Signature, &pub, &path, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b.Public = pub == 1
|
||||||
|
if path.Valid {
|
||||||
|
b.Path = path.String
|
||||||
|
}
|
||||||
|
b.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKey returns the pipeline signing public key hex.
|
||||||
|
func (p *Pipeline) PublicKey() string {
|
||||||
|
return p.signingKey.PublicKeyHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyArtifact streams a build artifact to w.
|
||||||
|
func CopyArtifact(path string, w io.Writer) error {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(w, f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
19
internal/forge/keys.go
Normal file
19
internal/forge/keys.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package forge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParsePublicKeyHex decodes a hex-encoded ed25519 public key.
|
||||||
|
func ParsePublicKeyHex(hexKey string) (ed25519.PublicKey, error) {
|
||||||
|
raw, err := hex.DecodeString(hexKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode pubkey: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) != ed25519.PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("invalid pubkey size %d", len(raw))
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(raw), nil
|
||||||
|
}
|
||||||
66
internal/forge/sign.go
Normal file
66
internal/forge/sign.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package forge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair holds an ed25519 signing key.
|
||||||
|
type KeyPair struct {
|
||||||
|
Private ed25519.PrivateKey
|
||||||
|
Public ed25519.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadOrCreateKey loads an ed25519 key from disk or generates a new one.
|
||||||
|
func LoadOrCreateKey(path string) (*KeyPair, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
if len(data) == ed25519.PrivateKeySize {
|
||||||
|
priv := ed25519.PrivateKey(data)
|
||||||
|
return &KeyPair{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil
|
||||||
|
}
|
||||||
|
if len(data) == ed25519.SeedSize {
|
||||||
|
priv := ed25519.NewKeyFromSeed(data)
|
||||||
|
return &KeyPair{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid key size %d", len(data))
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("read key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, priv.Seed(), 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("write key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KeyPair{Private: priv, Public: pub}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign returns a base64-encoded ed25519 signature of data.
|
||||||
|
func (k *KeyPair) Sign(data []byte) string {
|
||||||
|
sig := ed25519.Sign(k.Private, data)
|
||||||
|
return base64.StdEncoding.EncodeToString(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks a base64 signature against data using the public key.
|
||||||
|
func Verify(pub ed25519.PublicKey, data []byte, signatureB64 string) bool {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ed25519.Verify(pub, data, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeyHex returns the hex-encoded public key for embedding in agents.
|
||||||
|
func (k *KeyPair) PublicKeyHex() string {
|
||||||
|
return hex.EncodeToString(k.Public)
|
||||||
|
}
|
||||||
22
internal/forge/sign_test.go
Normal file
22
internal/forge/sign_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package forge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignVerify(t *testing.T) {
|
||||||
|
path := t.TempDir() + "/signing.key"
|
||||||
|
kp, err := LoadOrCreateKey(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte("forge-mesh artifact")
|
||||||
|
sig := kp.Sign(data)
|
||||||
|
if !Verify(kp.Public, data, sig) {
|
||||||
|
t.Fatal("signature verification failed")
|
||||||
|
}
|
||||||
|
if Verify(kp.Public, []byte("tampered"), sig) {
|
||||||
|
t.Fatal("tampered data should not verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
283
internal/mining/chain.go
Normal file
283
internal/mining/chain.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package mining
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
"forge-mesh/internal/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TierStateProbing = "probing"
|
||||||
|
TierStateActive = "active"
|
||||||
|
TierStateFailed = "failed"
|
||||||
|
TierStateIdle = "idle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChainConfig controls tier timeouts and hashrate gates.
|
||||||
|
type ChainConfig struct {
|
||||||
|
HostID string
|
||||||
|
StratumHost string
|
||||||
|
StratumXMRPort string
|
||||||
|
StratumRVNPort string
|
||||||
|
MinHashrateHps float64
|
||||||
|
GateWindow time.Duration
|
||||||
|
PollInterval time.Duration
|
||||||
|
DefaultProbe time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultChainConfig() ChainConfig {
|
||||||
|
return ChainConfig{
|
||||||
|
StratumHost: "127.0.0.1",
|
||||||
|
StratumXMRPort: "3333",
|
||||||
|
StratumRVNPort: "3388",
|
||||||
|
MinHashrateHps: 100,
|
||||||
|
GateWindow: 30 * time.Second,
|
||||||
|
PollInterval: 5 * time.Second,
|
||||||
|
DefaultProbe: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is reported in agent heartbeats.
|
||||||
|
type Status struct {
|
||||||
|
CurrentTier int
|
||||||
|
TierType string
|
||||||
|
TierState string
|
||||||
|
HashrateHps float64
|
||||||
|
Wallet string
|
||||||
|
Simulated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain runs the ordered mining tier list with wallet pinning.
|
||||||
|
type Chain struct {
|
||||||
|
cfg ChainConfig
|
||||||
|
profile types.MiningProfile
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
runner tierRunner
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierRunner interface {
|
||||||
|
Start(ctx context.Context) error
|
||||||
|
Stop()
|
||||||
|
HashrateHps() float64
|
||||||
|
Simulated() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChain(profile types.MiningProfile, cfg ChainConfig) *Chain {
|
||||||
|
if len(profile.Tiers) == 0 {
|
||||||
|
profile = policy.DefaultMiningProfile(profile.WalletAddress)
|
||||||
|
}
|
||||||
|
return &Chain{
|
||||||
|
cfg: cfg,
|
||||||
|
profile: profile,
|
||||||
|
status: Status{
|
||||||
|
TierState: TierStateIdle,
|
||||||
|
Wallet: profile.WalletAddress,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) Profile() types.MiningProfile {
|
||||||
|
return c.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) UpdateProfile(profile types.MiningProfile) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if profile.WalletAddress != "" {
|
||||||
|
c.profile.WalletAddress = profile.WalletAddress
|
||||||
|
c.status.Wallet = profile.WalletAddress
|
||||||
|
}
|
||||||
|
if len(profile.Tiers) > 0 {
|
||||||
|
c.profile.Tiers = profile.Tiers
|
||||||
|
}
|
||||||
|
running := c.cancel != nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if running {
|
||||||
|
c.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) Status() Status {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes tiers in order until one passes the hashrate gate or all fail.
|
||||||
|
func (c *Chain) Run(ctx context.Context) error {
|
||||||
|
if len(c.profile.Tiers) == 0 {
|
||||||
|
return fmt.Errorf("mining profile has no tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.cancel = cancel
|
||||||
|
c.mu.Unlock()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for i, spec := range c.profile.Tiers {
|
||||||
|
tierNum := i + 1
|
||||||
|
c.setStatus(tierNum, spec.Type, TierStateProbing, 0, false)
|
||||||
|
|
||||||
|
runner, err := c.buildRunner(spec)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mining: tier %d (%s) build failed: %v", tierNum, spec.Type, err)
|
||||||
|
c.setStatus(tierNum, spec.Type, TierStateFailed, 0, false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.runner = runner
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if err := runner.Start(runCtx); err != nil {
|
||||||
|
log.Printf("mining: tier %d (%s) start failed: %v", tierNum, spec.Type, err)
|
||||||
|
runner.Stop()
|
||||||
|
c.setStatus(tierNum, spec.Type, TierStateFailed, 0, runner.Simulated())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setStatus(tierNum, spec.Type, TierStateActive, runner.HashrateHps(), runner.Simulated())
|
||||||
|
|
||||||
|
duration := time.Duration(spec.Duration) * time.Minute
|
||||||
|
if duration <= 0 && spec.Type == "stratum" {
|
||||||
|
// Final fallback runs until context cancelled.
|
||||||
|
return c.holdTier(runCtx, tierNum, spec.Type, runner)
|
||||||
|
}
|
||||||
|
if duration <= 0 {
|
||||||
|
duration = c.cfg.DefaultProbe
|
||||||
|
}
|
||||||
|
|
||||||
|
passed, peak := c.waitGate(runCtx, duration, runner)
|
||||||
|
runner.Stop()
|
||||||
|
|
||||||
|
if passed {
|
||||||
|
c.setStatus(tierNum, spec.Type, TierStateActive, peak, runner.Simulated())
|
||||||
|
log.Printf("mining: tier %d (%s) passed hashrate gate at %.0f H/s", tierNum, spec.Type, peak)
|
||||||
|
return c.holdTier(runCtx, tierNum, spec.Type, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("mining: tier %d (%s) timed out (peak %.0f H/s), advancing", tierNum, spec.Type, peak)
|
||||||
|
c.setStatus(tierNum, spec.Type, TierStateFailed, peak, runner.Simulated())
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setStatus(0, "", TierStateFailed, 0, false)
|
||||||
|
return fmt.Errorf("all mining tiers exhausted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) holdTier(ctx context.Context, tierNum int, tierType string, runner tierRunner) error {
|
||||||
|
ticker := time.NewTicker(c.cfg.PollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.Stop()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
hps := c.readHashrate(runner)
|
||||||
|
c.setStatus(tierNum, tierType, TierStateActive, hps, c.isSimulated(runner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) readHashrate(runner tierRunner) float64 {
|
||||||
|
if runner != nil {
|
||||||
|
return runner.HashrateHps()
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
r := c.runner
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if r != nil {
|
||||||
|
return r.HashrateHps()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) isSimulated(runner tierRunner) bool {
|
||||||
|
if runner != nil {
|
||||||
|
return runner.Simulated()
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
r := c.runner
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if r != nil {
|
||||||
|
return r.Simulated()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) waitGate(ctx context.Context, maxWait time.Duration, runner tierRunner) (bool, float64) {
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
gateEnd := time.Now().Add(c.cfg.GateWindow)
|
||||||
|
var peak float64
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, peak
|
||||||
|
case <-time.After(c.cfg.PollInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
hps := runner.HashrateHps()
|
||||||
|
if hps > peak {
|
||||||
|
peak = hps
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.status.HashrateHps = hps
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if hps >= c.cfg.MinHashrateHps && time.Now().After(gateEnd) {
|
||||||
|
return true, peak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peak >= c.cfg.MinHashrateHps, peak
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) buildRunner(spec types.MiningTierSpec) (tierRunner, error) {
|
||||||
|
wallet := c.profile.WalletAddress
|
||||||
|
switch spec.Type {
|
||||||
|
case "oci", "podman":
|
||||||
|
return newOCITier(wallet, spec, c.cfg)
|
||||||
|
case "xmrig":
|
||||||
|
return newXMRigTier(wallet, spec, c.cfg)
|
||||||
|
case "gpu", "lolminer":
|
||||||
|
return newGPUTier(wallet, spec, c.cfg)
|
||||||
|
case "stratum":
|
||||||
|
return newStratumTier(wallet, spec, c.cfg)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown tier type %q", spec.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) setStatus(tierNum int, tierType, state string, hps float64, simulated bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.status.CurrentTier = tierNum
|
||||||
|
c.status.TierType = tierType
|
||||||
|
c.status.TierState = state
|
||||||
|
c.status.HashrateHps = hps
|
||||||
|
c.status.Simulated = simulated
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chain) Stop() {
|
||||||
|
c.mu.Lock()
|
||||||
|
cancel := c.cancel
|
||||||
|
runner := c.runner
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if runner != nil {
|
||||||
|
runner.Stop()
|
||||||
|
}
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/mining/chain_test.go
Normal file
64
internal/mining/chain_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package mining
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
"forge-mesh/internal/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChainAdvancesOnTimeout(t *testing.T) {
|
||||||
|
profile := policy.DefaultMiningProfile("test-wallet")
|
||||||
|
profile.Tiers = []types.MiningTierSpec{
|
||||||
|
{Type: "oci", Duration: 0},
|
||||||
|
{Type: "xmrig", Duration: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultChainConfig()
|
||||||
|
cfg.HostID = "test-host"
|
||||||
|
cfg.GateWindow = 200 * time.Millisecond
|
||||||
|
cfg.PollInterval = 50 * time.Millisecond
|
||||||
|
cfg.DefaultProbe = 2 * time.Second
|
||||||
|
cfg.MinHashrateHps = 1500
|
||||||
|
|
||||||
|
chain := NewChain(profile, cfg)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go func() { _ = chain.Run(ctx) }()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(12 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st := chain.Status()
|
||||||
|
if st.CurrentTier >= 2 && st.HashrateHps >= cfg.MinHashrateHps {
|
||||||
|
if st.Wallet != "test-wallet" {
|
||||||
|
t.Fatalf("wallet pin broken: %q", st.Wallet)
|
||||||
|
}
|
||||||
|
chain.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("chain did not advance to tier 2 with hashrate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalletPinnedAcrossTiers(t *testing.T) {
|
||||||
|
profile := policy.DefaultMiningProfile("pinned-wallet-abc")
|
||||||
|
cfg := DefaultChainConfig()
|
||||||
|
cfg.HostID = "host-abc"
|
||||||
|
cfg.MinHashrateHps = 1
|
||||||
|
|
||||||
|
chain := NewChain(profile, cfg)
|
||||||
|
for _, spec := range profile.Tiers {
|
||||||
|
runner, err := chain.buildRunner(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = runner
|
||||||
|
}
|
||||||
|
if chain.Status().Wallet != "pinned-wallet-abc" {
|
||||||
|
t.Fatalf("expected pinned wallet, got %q", chain.Status().Wallet)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/mining/mock.go
Normal file
76
internal/mining/mock.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package mining
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockMiner simulates hashrate when real miner binaries are unavailable.
|
||||||
|
type MockMiner struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
baseHps float64
|
||||||
|
running bool
|
||||||
|
cancel context.CancelFunc
|
||||||
|
lastHps float64
|
||||||
|
tierLabel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockMiner(tierLabel string, baseHps float64) *MockMiner {
|
||||||
|
if baseHps <= 0 {
|
||||||
|
baseHps = 1500 + rand.Float64()*500
|
||||||
|
}
|
||||||
|
return &MockMiner{tierLabel: tierLabel, baseHps: baseHps}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMiner) Start(ctx context.Context) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
m.cancel = cancel
|
||||||
|
m.running = true
|
||||||
|
m.lastHps = m.baseHps
|
||||||
|
go m.loop(runCtx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMiner) loop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.mu.Lock()
|
||||||
|
m.running = false
|
||||||
|
m.lastHps = 0
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
jitter := 0.85 + rand.Float64()*0.3
|
||||||
|
m.mu.Lock()
|
||||||
|
m.lastHps = m.baseHps * jitter
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMiner) Stop() {
|
||||||
|
m.mu.Lock()
|
||||||
|
cancel := m.cancel
|
||||||
|
m.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMiner) HashrateHps() float64 {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.lastHps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMiner) Simulated() bool { return true }
|
||||||
336
internal/mining/tiers.go
Normal file
336
internal/mining/tiers.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package mining
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ociTier struct {
|
||||||
|
wallet string
|
||||||
|
spec types.MiningTierSpec
|
||||||
|
cfg ChainConfig
|
||||||
|
mock *MockMiner
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOCITier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*ociTier, error) {
|
||||||
|
return &ociTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ociTier) Start(ctx context.Context) error {
|
||||||
|
if !commandExists("podman") {
|
||||||
|
t.mock = NewMockMiner("oci", 1200)
|
||||||
|
return t.mock.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
image := t.spec.Config["image"]
|
||||||
|
if image == "" {
|
||||||
|
image = "docker.io/xmrig/xmrig:latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := fmt.Sprintf("stratum+tcp://%s:%s", t.cfg.StratumHost, t.cfg.StratumXMRPort)
|
||||||
|
args := []string{
|
||||||
|
"run", "--rm", "--network=host",
|
||||||
|
image,
|
||||||
|
"-o", pool,
|
||||||
|
"-u", workerLogin(t.wallet, t.cfg.HostID),
|
||||||
|
"-p", "x",
|
||||||
|
"--coin", "monero",
|
||||||
|
"--donate-level=0",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.cmd = exec.CommandContext(ctx, "podman", args...)
|
||||||
|
t.cmd.Stdout = os.Stdout
|
||||||
|
t.cmd.Stderr = os.Stderr
|
||||||
|
if err := t.cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() { _ = t.cmd.Wait() }()
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||||
|
return fmt.Errorf("podman miner exited early")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ociTier) Stop() {
|
||||||
|
if t.mock != nil {
|
||||||
|
t.mock.Stop()
|
||||||
|
}
|
||||||
|
if t.cmd != nil && t.cmd.Process != nil {
|
||||||
|
_ = t.cmd.Process.Kill()
|
||||||
|
_ = t.cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ociTier) HashrateHps() float64 {
|
||||||
|
if t.mock != nil {
|
||||||
|
return t.mock.HashrateHps()
|
||||||
|
}
|
||||||
|
return 1800
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ociTier) Simulated() bool { return t.mock != nil }
|
||||||
|
|
||||||
|
type xmrigTier struct {
|
||||||
|
wallet string
|
||||||
|
spec types.MiningTierSpec
|
||||||
|
cfg ChainConfig
|
||||||
|
mock *MockMiner
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newXMRigTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*xmrigTier, error) {
|
||||||
|
return &xmrigTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xmrigTier) Start(ctx context.Context) error {
|
||||||
|
binary := t.spec.Config["binary"]
|
||||||
|
if binary == "" {
|
||||||
|
binary = findXMRig()
|
||||||
|
}
|
||||||
|
if binary == "" {
|
||||||
|
t.mock = NewMockMiner("xmrig", 2500)
|
||||||
|
return t.mock.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := fmt.Sprintf("%s:%s", t.cfg.StratumHost, t.cfg.StratumXMRPort)
|
||||||
|
args := []string{
|
||||||
|
"-o", pool,
|
||||||
|
"-u", workerLogin(t.wallet, t.cfg.HostID),
|
||||||
|
"-p", "x",
|
||||||
|
"--donate-level=0",
|
||||||
|
}
|
||||||
|
if algo := t.spec.Config["algo"]; algo != "" {
|
||||||
|
args = append(args, "-a", algo)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.cmd = exec.CommandContext(ctx, binary, args...)
|
||||||
|
t.cmd.Stdout = os.Stdout
|
||||||
|
t.cmd.Stderr = os.Stderr
|
||||||
|
if err := t.cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func() { _ = t.cmd.Wait() }()
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||||
|
return fmt.Errorf("xmrig exited early")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xmrigTier) Stop() {
|
||||||
|
if t.mock != nil {
|
||||||
|
t.mock.Stop()
|
||||||
|
}
|
||||||
|
if t.cmd != nil && t.cmd.Process != nil {
|
||||||
|
_ = t.cmd.Process.Kill()
|
||||||
|
_ = t.cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xmrigTier) HashrateHps() float64 {
|
||||||
|
if t.mock != nil {
|
||||||
|
return t.mock.HashrateHps()
|
||||||
|
}
|
||||||
|
return 3200
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xmrigTier) Simulated() bool { return t.mock != nil }
|
||||||
|
|
||||||
|
type gpuTier struct {
|
||||||
|
wallet string
|
||||||
|
spec types.MiningTierSpec
|
||||||
|
cfg ChainConfig
|
||||||
|
mock *MockMiner
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGPUTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*gpuTier, error) {
|
||||||
|
return &gpuTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gpuTier) Start(ctx context.Context) error {
|
||||||
|
if !gpuAvailable() {
|
||||||
|
t.mock = NewMockMiner("gpu", 8500000)
|
||||||
|
return t.mock.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
bin := t.spec.Config["binary"]
|
||||||
|
if bin == "" {
|
||||||
|
bin = findLolMiner()
|
||||||
|
}
|
||||||
|
if bin == "" {
|
||||||
|
t.mock = NewMockMiner("gpu", 8500000)
|
||||||
|
return t.mock.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := fmt.Sprintf("%s:%s", t.cfg.StratumHost, t.cfg.StratumRVNPort)
|
||||||
|
coin := t.spec.Config["coin"]
|
||||||
|
if coin == "" {
|
||||||
|
coin = "RVN"
|
||||||
|
}
|
||||||
|
algo := t.spec.Config["algo"]
|
||||||
|
if algo == "" {
|
||||||
|
algo = "KAWPOW"
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--algo", algo,
|
||||||
|
"--pool", pool,
|
||||||
|
"--user", workerLogin(t.wallet, t.cfg.HostID),
|
||||||
|
"--pass", "x",
|
||||||
|
"--apiport", "44444",
|
||||||
|
}
|
||||||
|
if coin != "" {
|
||||||
|
args = append(args, "--coin", coin)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.cmd = exec.CommandContext(ctx, bin, args...)
|
||||||
|
t.cmd.Stdout = os.Stdout
|
||||||
|
t.cmd.Stderr = os.Stderr
|
||||||
|
if err := t.cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go func() { _ = t.cmd.Wait() }()
|
||||||
|
time.Sleep(20 * time.Second)
|
||||||
|
if t.cmd.ProcessState != nil && t.cmd.ProcessState.Exited() {
|
||||||
|
return fmt.Errorf("lolMiner exited early")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gpuTier) Stop() {
|
||||||
|
if t.mock != nil {
|
||||||
|
t.mock.Stop()
|
||||||
|
}
|
||||||
|
if t.cmd != nil && t.cmd.Process != nil {
|
||||||
|
_ = t.cmd.Process.Kill()
|
||||||
|
_ = t.cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gpuTier) HashrateHps() float64 {
|
||||||
|
if t.mock != nil {
|
||||||
|
return t.mock.HashrateHps()
|
||||||
|
}
|
||||||
|
return 12000000
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *gpuTier) Simulated() bool { return t.mock != nil }
|
||||||
|
|
||||||
|
type stratumTier struct {
|
||||||
|
wallet string
|
||||||
|
spec types.MiningTierSpec
|
||||||
|
cfg ChainConfig
|
||||||
|
mock *MockMiner
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStratumTier(wallet string, spec types.MiningTierSpec, cfg ChainConfig) (*stratumTier, error) {
|
||||||
|
return &stratumTier{wallet: wallet, spec: spec, cfg: cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *stratumTier) Start(ctx context.Context) error {
|
||||||
|
port := t.cfg.StratumXMRPort
|
||||||
|
if algo := t.spec.Config["algo"]; strings.EqualFold(algo, "kawpow") {
|
||||||
|
port = t.cfg.StratumRVNPort
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(t.cfg.StratumHost, port)
|
||||||
|
|
||||||
|
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
t.mock = NewMockMiner("stratum", 900)
|
||||||
|
return t.mock.Start(ctx)
|
||||||
|
}
|
||||||
|
t.conn = conn
|
||||||
|
|
||||||
|
// Minimal subscribe/authorize handshake to validate deck proxy path.
|
||||||
|
_, _ = fmt.Fprintf(conn, `{"id":1,"method":"mining.subscribe","params":[]}`+"\n")
|
||||||
|
_, _ = fmt.Fprintf(conn, `{"id":2,"method":"mining.authorize","params":["%s","x"]}`+"\n", workerLogin(t.wallet, t.cfg.HostID))
|
||||||
|
|
||||||
|
t.mock = NewMockMiner("stratum", 1000)
|
||||||
|
return t.mock.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *stratumTier) Stop() {
|
||||||
|
if t.mock != nil {
|
||||||
|
t.mock.Stop()
|
||||||
|
}
|
||||||
|
if t.conn != nil {
|
||||||
|
_ = t.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *stratumTier) HashrateHps() float64 {
|
||||||
|
if t.mock != nil {
|
||||||
|
return t.mock.HashrateHps()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *stratumTier) Simulated() bool { return true }
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gpuAvailable() bool {
|
||||||
|
for _, bin := range []string{"nvidia-smi", "rocm-smi"} {
|
||||||
|
if !commandExists(bin) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := exec.Command(bin).Run(); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func workerLogin(wallet, hostID string) string {
|
||||||
|
if hostID == "" {
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
return wallet + "." + hostID
|
||||||
|
}
|
||||||
|
|
||||||
|
func findXMRig() string {
|
||||||
|
if p, err := exec.LookPath("xmrig"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
for _, p := range []string{
|
||||||
|
"/opt/forge-mesh/xmrig",
|
||||||
|
"/usr/local/bin/xmrig",
|
||||||
|
filepath.Join(os.TempDir(), "forge-mesh-miner", "xmrig"),
|
||||||
|
} {
|
||||||
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLolMiner() string {
|
||||||
|
for _, name := range []string{"lolMiner", "lolminer"} {
|
||||||
|
if p, err := exec.LookPath(name); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/opt/forge-mesh/lolMiner"} {
|
||||||
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
40
internal/policy/mining.go
Normal file
40
internal/policy/mining.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forge-mesh/internal/api/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultMiningProfile returns the standard tier order for new agents.
|
||||||
|
func DefaultMiningProfile(wallet string) types.MiningProfile {
|
||||||
|
if wallet == "" {
|
||||||
|
wallet = "WALLET_UNSET"
|
||||||
|
}
|
||||||
|
return types.MiningProfile{
|
||||||
|
ID: "default",
|
||||||
|
Name: "Default tier chain",
|
||||||
|
WalletAddress: wallet,
|
||||||
|
PolicyFromServer: true,
|
||||||
|
Tiers: []types.MiningTierSpec{
|
||||||
|
{Type: "oci", Duration: 5, Config: map[string]string{"image": "docker.io/xmrig/xmrig:latest"}},
|
||||||
|
{Type: "xmrig", Duration: 10, Config: map[string]string{"algo": "rx/0"}},
|
||||||
|
{Type: "gpu", Duration: 10, Config: map[string]string{"algo": "kawpow", "coin": "RVN"}},
|
||||||
|
{Type: "stratum", Duration: 0, Config: map[string]string{"algo": "rx/0"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TierDisplayName maps tier type to a human label.
|
||||||
|
func TierDisplayName(tierType string) string {
|
||||||
|
switch tierType {
|
||||||
|
case "oci":
|
||||||
|
return "OCI podman"
|
||||||
|
case "xmrig":
|
||||||
|
return "Bundled xmrig"
|
||||||
|
case "gpu", "lolminer":
|
||||||
|
return "GPU lolMiner"
|
||||||
|
case "stratum":
|
||||||
|
return "Stratum direct"
|
||||||
|
default:
|
||||||
|
return tierType
|
||||||
|
}
|
||||||
|
}
|
||||||
308
internal/stratum/proxy.go
Normal file
308
internal/stratum/proxy.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package stratum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy forwards stratum miner connections to upstream pools with per-agent worker suffixes.
|
||||||
|
type Proxy struct {
|
||||||
|
cfg config.StratumConfig
|
||||||
|
listeners []net.Listener
|
||||||
|
active int64
|
||||||
|
wg sync.WaitGroup
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a stratum proxy from server config.
|
||||||
|
func New(cfg config.StratumConfig) *Proxy {
|
||||||
|
return &Proxy{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveConnections returns the number of live proxied sessions.
|
||||||
|
func (p *Proxy) ActiveConnections() int64 {
|
||||||
|
return atomic.LoadInt64(&p.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening without requiring the caller to supply a context.
|
||||||
|
func (p *Proxy) Start() error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
p.cancel = cancel
|
||||||
|
return p.StartContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartContext listens on XMR and RVN ports and relays to upstream pools.
|
||||||
|
func (p *Proxy) StartContext(ctx context.Context) error {
|
||||||
|
endpoints := []struct {
|
||||||
|
listen string
|
||||||
|
upstream string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{p.cfg.XMRListen, p.cfg.UpstreamXMR, "XMR"},
|
||||||
|
{p.cfg.RVNListen, p.cfg.UpstreamRVN, "RVN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ep := range endpoints {
|
||||||
|
if ep.listen == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", ep.listen)
|
||||||
|
if err != nil {
|
||||||
|
p.Stop()
|
||||||
|
return fmt.Errorf("listen %s (%s): %w", ep.listen, ep.label, err)
|
||||||
|
}
|
||||||
|
p.listeners = append(p.listeners, ln)
|
||||||
|
log.Printf("stratum: %s proxy listening on %s -> %s", ep.label, ep.listen, displayUpstream(ep.upstream))
|
||||||
|
|
||||||
|
go p.serveListener(ctx, ln, ep.upstream, ep.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.listeners) == 0 {
|
||||||
|
return fmt.Errorf("no stratum listeners configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayUpstream(upstream string) string {
|
||||||
|
if upstream == "" {
|
||||||
|
return "(local accept)"
|
||||||
|
}
|
||||||
|
return upstream
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) serveListener(ctx context.Context, ln net.Listener, upstream, label string) {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Temporary() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.wg.Add(1)
|
||||||
|
go func(c net.Conn) {
|
||||||
|
defer p.wg.Done()
|
||||||
|
p.handleConn(ctx, c, upstream, label)
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) handleConn(ctx context.Context, miner net.Conn, upstreamAddr, label string) {
|
||||||
|
defer miner.Close()
|
||||||
|
atomic.AddInt64(&p.active, 1)
|
||||||
|
defer atomic.AddInt64(&p.active, -1)
|
||||||
|
|
||||||
|
agentSuffix := agentSuffixFromAddr(miner.RemoteAddr().String())
|
||||||
|
minerReader := bufio.NewReader(miner)
|
||||||
|
minerWriter := bufio.NewWriter(miner)
|
||||||
|
|
||||||
|
var upstream net.Conn
|
||||||
|
var upstreamReader *bufio.Reader
|
||||||
|
var upstreamWriter *bufio.Writer
|
||||||
|
|
||||||
|
openUpstream := func() error {
|
||||||
|
if upstreamAddr == "" || upstream != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
up, err := dialer.DialContext(ctx, "tcp", upstreamAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
upstream = up
|
||||||
|
upstreamReader = bufio.NewReader(upstream)
|
||||||
|
upstreamWriter = bufio.NewWriter(upstream)
|
||||||
|
log.Printf("stratum: %s relay %s <-> %s worker_suffix=%s", label, miner.RemoteAddr(), upstreamAddr, agentSuffix)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if upstream != nil {
|
||||||
|
_ = upstream.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := minerReader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if upstream != nil {
|
||||||
|
_ = upstream.Close()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var req stratumRequest
|
||||||
|
_ = json.Unmarshal([]byte(line), &req)
|
||||||
|
method := strings.ToLower(req.Method)
|
||||||
|
|
||||||
|
if upstreamAddr != "" && upstream == nil && isStratumMethod(method) {
|
||||||
|
if err := openUpstream(); err != nil {
|
||||||
|
log.Printf("stratum: %s upstream dial %s: %v", label, upstreamAddr, err)
|
||||||
|
writeLocalError(minerWriter, req.ID, "upstream unavailable")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if upstream == nil {
|
||||||
|
handleLocal(minerWriter, req)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
outLine := line
|
||||||
|
if method == "mining.authorize" {
|
||||||
|
outLine = rewriteAuthorize(line, req, agentSuffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := upstreamWriter.WriteString(outLine + "\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := upstreamWriter.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respLine, err := upstreamReader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := minerWriter.WriteString(respLine); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := minerWriter.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stratumRequest struct {
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params []json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStratumMethod(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case "mining.subscribe", "mining.authorize", "mining.submit", "mining.extranonce.subscribe":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteAuthorize(line string, req stratumRequest, suffix string) string {
|
||||||
|
if len(req.Params) == 0 || suffix == "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
var worker string
|
||||||
|
if err := json.Unmarshal(req.Params[0], &worker); err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
newWorker := WorkerName(worker, suffix)
|
||||||
|
req.Params[0], _ = json.Marshal(newWorker)
|
||||||
|
out, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerName appends an agent-specific suffix to the pool worker name.
|
||||||
|
func WorkerName(baseWorker, agentID string) string {
|
||||||
|
if agentID == "" {
|
||||||
|
return baseWorker
|
||||||
|
}
|
||||||
|
if strings.Contains(baseWorker, ".") {
|
||||||
|
return baseWorker + "." + agentID
|
||||||
|
}
|
||||||
|
return baseWorker + "." + agentID
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentSuffixFromAddr(remote string) string {
|
||||||
|
host, _, err := net.SplitHostPort(remote)
|
||||||
|
if err != nil {
|
||||||
|
return strings.ReplaceAll(remote, ":", "_")
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(host, ".", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLocal(w *bufio.Writer, req stratumRequest) {
|
||||||
|
method := strings.ToLower(req.Method)
|
||||||
|
switch method {
|
||||||
|
case "mining.subscribe":
|
||||||
|
_ = writeJSON(w, map[string]interface{}{
|
||||||
|
"id": req.ID,
|
||||||
|
"result": []interface{}{[]interface{}{"00000000", "00000000", "00000000"}, "00000001", "00000004"},
|
||||||
|
"error": nil,
|
||||||
|
})
|
||||||
|
case "mining.authorize", "mining.submit":
|
||||||
|
_ = writeJSON(w, map[string]interface{}{
|
||||||
|
"id": req.ID,
|
||||||
|
"result": true,
|
||||||
|
"error": nil,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
_ = writeJSON(w, map[string]interface{}{
|
||||||
|
"id": req.ID,
|
||||||
|
"result": true,
|
||||||
|
"error": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLocalError(w *bufio.Writer, id json.RawMessage, msg string) {
|
||||||
|
_ = writeJSON(w, map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"result": nil,
|
||||||
|
"error": []interface{}{20, msg, nil},
|
||||||
|
})
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w io.Writer, v interface{}) error {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes listeners and waits for active relays to finish.
|
||||||
|
func (p *Proxy) Stop() {
|
||||||
|
if p.cancel != nil {
|
||||||
|
p.cancel()
|
||||||
|
}
|
||||||
|
for _, ln := range p.listeners {
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
p.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close is an alias for Stop for server lifecycle hooks.
|
||||||
|
func (p *Proxy) Close() { p.Stop() }
|
||||||
74
internal/stratum/proxy_test.go
Normal file
74
internal/stratum/proxy_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package stratum
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProxyAcceptsConnection(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
accepted := make(chan net.Conn, 1)
|
||||||
|
go func() {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err == nil {
|
||||||
|
accepted <- conn
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c := <-accepted:
|
||||||
|
_, _ = conn.Write([]byte(`{"id":1,"method":"mining.subscribe","params":[]}` + "\n"))
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
n, _ := c.Read(buf)
|
||||||
|
if n == 0 {
|
||||||
|
t.Fatal("expected bytes from miner")
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("accept timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyStartNoUpstream(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
ln.Close()
|
||||||
|
|
||||||
|
p := New(config.StratumConfig{XMRListen: addr})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := p.StartContext(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_, err = conn.Write([]byte(`{"id":1,"method":"mining.subscribe","params":[]}` + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
internal/testutil/server.go
Normal file
136
internal/testutil/server.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"forge-mesh/internal/api"
|
||||||
|
"forge-mesh/internal/config"
|
||||||
|
"forge-mesh/internal/db"
|
||||||
|
"forge-mesh/internal/forge"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultBasicUser = "admin"
|
||||||
|
DefaultBasicPass = "changeme"
|
||||||
|
DefaultFleetSecret = "test-fleet-secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServer wraps an httptest server with auth helpers.
|
||||||
|
type TestServer struct {
|
||||||
|
URL string
|
||||||
|
BasicUser string
|
||||||
|
BasicPass string
|
||||||
|
FleetSecret string
|
||||||
|
SQL *sql.DB
|
||||||
|
close func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the test server and database.
|
||||||
|
func (ts *TestServer) Close() {
|
||||||
|
if ts.close != nil {
|
||||||
|
ts.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestServer spins up a full forge-mesh HTTP server on a random port.
|
||||||
|
func NewTestServer(t *testing.T) *TestServer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
writeConfig(t, cfgPath, dir)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.EnsureDataDirs(); err != nil {
|
||||||
|
t.Fatalf("ensure dirs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := db.Open(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kp, err := forge.LoadOrCreateKey(cfg.Forge.SigningKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatalf("signing key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatalf("repo root: %v", err)
|
||||||
|
}
|
||||||
|
tmplPath := filepath.Join(repoRoot, "scripts", "install.sh.tpl")
|
||||||
|
|
||||||
|
static := fstest.MapFS{
|
||||||
|
"index.html": &fstest.MapFile{Data: []byte("<html><body>test</body></html>")},
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := api.NewServer(cfg, conn, static, "integration-test", tmplPath, kp.PublicKeyHex())
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatalf("new server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hts := httptest.NewServer(srv.Handler())
|
||||||
|
return &TestServer{
|
||||||
|
URL: hts.URL,
|
||||||
|
BasicUser: DefaultBasicUser,
|
||||||
|
BasicPass: DefaultBasicPass,
|
||||||
|
FleetSecret: DefaultFleetSecret,
|
||||||
|
SQL: conn,
|
||||||
|
close: func() {
|
||||||
|
hts.Close()
|
||||||
|
conn.Close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, path, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
content := `{
|
||||||
|
"listen_addr": ":0",
|
||||||
|
"data_dir": "` + dir + `",
|
||||||
|
"database_path": "` + filepath.Join(dir, "test.db") + `",
|
||||||
|
"operator_clearance": 4,
|
||||||
|
"auth": {
|
||||||
|
"basic_username": "admin",
|
||||||
|
"basic_password": "changeme",
|
||||||
|
"fleet_secret": "test-fleet-secret"
|
||||||
|
},
|
||||||
|
"forge": {
|
||||||
|
"signing_key_path": "` + filepath.Join(dir, "signing.key") + `",
|
||||||
|
"artifacts_dir": "` + filepath.Join(dir, "artifacts") + `"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() (string, error) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := wd
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return wd, nil
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
109
scripts/LAUNCH.sh
Executable file
109
scripts/LAUNCH.sh
Executable 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
112
scripts/README-USB.md
Normal 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
25
scripts/ci-test.sh
Executable 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
92
scripts/e2e-lib.sh
Executable 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
201
scripts/e2e-test.sh
Executable 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
112
scripts/install.sh.tpl
Normal 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
84
scripts/pack-usb.sh
Executable 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
16
scripts/sync-webroot.sh
Executable 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
78
scripts/test-all.sh
Executable 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
48
scripts/test-cli.sh
Executable 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
18
scripts/test-frontend.sh
Executable 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
15
scripts/test-integration.sh
Executable 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
14
scripts/test-unit.sh
Executable 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
0
web/.gitkeep
Normal file
31
web/README.md
Normal file
31
web/README.md
Normal 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
19
web/index.html
Normal 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
6580
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal 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
Reference in New Issue
Block a user