Skip to content

Commit 76c0039

Browse files
committed
feat(agent): add expire_benchmarks action to re-benchmark agents
Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
1 parent 587684c commit 76c0039

8 files changed

Lines changed: 97 additions & 32 deletions

File tree

GOTCHAS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ Referenced from [AGENTS.md](AGENTS.md) — read the relevant section before work
213213

214214
- Broadcast partials (rendered by `broadcast_replace_to`/`broadcast_replace_later_to`) run in background jobs with NO `current_user` — partials must not reference `current_user` or session data
215215
- For targeted broadcasts, extract small partials (e.g., `_index_state.html.erb`) that wrap a single element with a stable DOM ID, following the Agent `broadcast_index_state` pattern
216+
- Never fragment-cache content containing `safe_can?` calls in broadcast-rendered partials — Sidekiq has no `current_user`, so `safe_can?` returns false and poisons the cache for all users. Keep auth-gated elements outside cache blocks.
217+
- Use `saved_changes.keys.intersect?(FIELDS)` or `saved_change_to_<attr>?` guards in `after_update_commit` callbacks to avoid broadcasting tabs whose data didn't change (see `Agent#broadcast_tab_updates`)
216218

217219
**Logging Patterns:**
218220

app/controllers/agents_controller.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ def update
7575
end
7676
end
7777

78+
# POST /agents/1/expire_benchmarks
79+
# Deletes all benchmarks for the agent and transitions it to pending,
80+
# forcing the agent to re-benchmark on its next heartbeat.
81+
def expire_benchmarks
82+
@agent.hashcat_benchmarks.destroy_all
83+
@agent.check_benchmark_age
84+
85+
redirect_to agent_url(@agent, anchor: "capabilities"),
86+
notice: "Benchmarks expired. The agent will re-benchmark on its next check-in."
87+
end
88+
7889
# DELETE /agents/1 or /agents/1.json
7990
def destroy
8091
@agent.destroy!

app/controllers/api/v1/client/tasks_controller.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,14 @@ def show
4040

4141
# Initializes a new task for the agent.
4242
# If the task is nil, it renders a no content status.
43+
#
44+
# Task assignment is benchmark-gated, not state-gated: if the agent has benchmarks
45+
# for a hash type, it can receive tasks for that type regardless of state.
46+
# We activate pending agents with benchmarks as a side effect so the UI reflects
47+
# reality, but this never blocks task assignment.
4348
def new
4449
@task = TaskAssignmentService.new(@agent).find_next_task
50+
activate_pending_agent_with_benchmarks
4551
head(:no_content) if @task.nil?
4652
# When @task exists, Jbuilder template (new.json.jbuilder) renders automatically
4753
end
@@ -225,6 +231,19 @@ def submit_status
225231

226232
private
227233

234+
# Activates a pending agent that has benchmark data on record.
235+
# This is a UI-correctness side effect, not a task assignment gate.
236+
def activate_pending_agent_with_benchmarks
237+
return unless @agent.pending? && @agent.hashcat_benchmarks.exists?
238+
return unless @agent.activate
239+
240+
Rails.logger.info(
241+
"[AgentLifecycle] auto_activate: agent_id=#{@agent.id} " \
242+
"reason=pending_with_benchmarks benchmark_count=#{@agent.hashcat_benchmarks.count} " \
243+
"timestamp=#{Time.zone.now}"
244+
)
245+
end
246+
228247
# Finds and sets the @task instance variable for the current agent.
229248
# This method is used as a before_action callback to ensure tasks exist and belong to the agent.
230249
# If the task is not found, it uses enhanced error handling from TaskErrorHandling concern.

app/models/ability.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def initialize(user)
6060
can :read, Agent, projects: { id: user.all_project_ids } # User can read agents in their projects
6161
can :update, Agent, user: user # User can update their own agents
6262
can :destroy, Agent, user: user # User can destroy their own agents
63+
can :expire_benchmarks, Agent, user: user # User can force re-benchmark their own agents
6364

6465
# Project permissions
6566
can :read, Project, project_users: { user_id: user.id } # User can read projects they are associated with

app/models/agent.rb

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ class Agent < ApplicationRecord
8888
quadrillion: "PH/s"
8989
}.freeze
9090

91+
# Fields whose changes should trigger a configuration tab broadcast.
92+
CONFIGURATION_BROADCAST_FIELDS = %w[
93+
enabled client_signature last_ipaddress advanced_configuration
94+
custom_label operating_system
95+
].freeze
96+
9197
belongs_to :user, touch: true
9298
has_and_belongs_to_many :projects, touch: true
9399
has_many :tasks, dependent: :destroy
@@ -136,23 +142,31 @@ def broadcast_index_last_seen
136142
locals: { agent: self }
137143
end
138144

145+
139146
# Broadcasts updates to individual tab streams instead of the root agent stream.
140147
# This allows each tab panel to update independently without affecting the active tab state.
148+
#
149+
# Overview: always broadcast (last_seen, state, metrics change frequently).
150+
# Configuration: only when config-relevant fields change.
151+
# Capabilities: only when state changes (benchmark data arrives via state transitions).
141152
def broadcast_tab_updates
142153
broadcast_replace_later_to [self, :overview],
143154
target: ActionView::RecordIdentifier.dom_id(self, :overview),
144155
partial: "agents/overview_tab",
145156
locals: { agent: self }
146157

147-
broadcast_replace_later_to [self, :configuration],
148-
target: ActionView::RecordIdentifier.dom_id(self, :configuration),
149-
partial: "agents/configuration_tab",
150-
locals: { agent: self }
158+
if saved_changes.keys.intersect?(CONFIGURATION_BROADCAST_FIELDS)
159+
broadcast_replace_later_to [self, :configuration],
160+
target: ActionView::RecordIdentifier.dom_id(self, :configuration),
161+
partial: "agents/configuration_tab",
162+
locals: { agent: self }
163+
end
151164

152-
broadcast_replace_later_to [self, :capabilities],
153-
target: ActionView::RecordIdentifier.dom_id(self, :capabilities),
154-
partial: "agents/capabilities_tab",
155-
locals: { agent: self }
165+
return unless saved_change_to_state?
166+
broadcast_replace_later_to [self, :capabilities],
167+
target: ActionView::RecordIdentifier.dom_id(self, :capabilities),
168+
partial: "agents/capabilities_tab",
169+
locals: { agent: self }
156170
end
157171

158172
# The operating system of the agent.

app/services/task_assignment_service.rb

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,21 @@ def find_unassigned_paused_task
129129
task = nil
130130

131131
Task.transaction do
132-
task = Task.with_state(:paused)
133-
.where(claimed_by_agent_id: nil)
134-
.where.not(agent_id: agent.id)
135-
.joins(:agent)
136-
.where(
137-
"tasks.paused_at IS NULL OR tasks.paused_at < :grace_cutoff OR agents.state IN (:orphan_states)",
138-
grace_cutoff: ApplicationConfig.agent_considered_offline_time.ago,
139-
orphan_states: %w[offline stopped]
140-
)
141-
.joins(attack: { campaign: :hash_list })
142-
.where(campaigns: { project_id: agent.project_ids })
143-
.where(hash_lists: { hash_type_id: allowed_hash_type_ids })
132+
scope = Task.with_state(:paused)
133+
.where(claimed_by_agent_id: nil)
134+
.where.not(agent_id: agent.id)
135+
.joins(:agent)
136+
.where(
137+
"tasks.paused_at IS NULL OR tasks.paused_at < :grace_cutoff OR agents.state IN (:orphan_states)",
138+
grace_cutoff: ApplicationConfig.agent_considered_offline_time.ago,
139+
orphan_states: %w[offline stopped]
140+
)
141+
.joins(attack: { campaign: :hash_list })
142+
.where(hash_lists: { hash_type_id: allowed_hash_type_ids })
143+
144+
scope = scope.where(campaigns: { project_id: agent.project_ids }) if agent.project_ids.present?
145+
146+
task = scope
144147
.where("EXISTS (SELECT 1 FROM hash_items WHERE hash_items.hash_list_id = hash_lists.id AND hash_items.cracked = false)")
145148
.order(:id)
146149
.lock("FOR UPDATE OF tasks SKIP LOCKED")
@@ -191,8 +194,6 @@ def find_unassigned_paused_task
191194
#
192195
# @return [Task, nil] the found or newly created task, or nil if none available
193196
def find_task_from_available_attacks
194-
return nil if agent.project_ids.blank?
195-
196197
available_attacks.each do |attack|
197198
next if attack.uncracked_count.zero?
198199

@@ -302,19 +303,24 @@ def create_new_task_if_eligible(attack)
302303

303304
# Returns attacks available for the agent based on projects and hash types.
304305
#
306+
# Agents with no project assignments can work on any project (same convention
307+
# as attack resources like word lists, rule lists, and Task#agent_compatible?).
308+
#
305309
# Ordering strategy:
306310
# 1. campaigns.priority DESC: Higher campaign priority first (high=2, normal=0, deferred=-1)
307311
# 2. attacks.complexity_value: Within same priority, simpler attacks first
308312
# 3. attacks.created_at: Tie-breaker for same priority and complexity
309313
#
310314
# @return [ActiveRecord::Relation<Attack>] attacks ordered by campaign priority, complexity, creation time
311315
def available_attacks
312-
Attack.incomplete
313-
.joins(campaign: { hash_list: :hash_type })
314-
.includes(campaign: %i[hash_list project])
315-
.where(campaigns: { project_id: agent.project_ids })
316-
.where(hash_lists: { hash_type_id: allowed_hash_type_ids })
317-
.order("campaigns.priority DESC, attacks.complexity_value, attacks.created_at")
316+
scope = Attack.incomplete
317+
.joins(campaign: { hash_list: :hash_type })
318+
.includes(campaign: %i[hash_list project])
319+
.where(hash_lists: { hash_type_id: allowed_hash_type_ids })
320+
321+
scope = scope.where(campaigns: { project_id: agent.project_ids }) if agent.project_ids.present?
322+
323+
scope.order("campaigns.priority DESC, attacks.complexity_value, attacks.created_at")
318324
end
319325

320326
# Returns hash type IDs the agent can work on, cached for performance.

app/views/agents/_capabilities_tab.html.erb

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@
3131
<div class="card-body">
3232
<% benchmarks = agent.last_benchmarks %>
3333
<% if benchmarks&.any? %>
34-
<% cache agent do %>
35-
<% if agent.last_benchmark_date.present? %>
36-
<div class="alert alert-info mb-3">
34+
<% if agent.last_benchmark_date.present? %>
35+
<div class="alert alert-info mb-3 d-flex justify-content-between align-items-center">
36+
<div>
3737
<small>
3838
Last benchmarked: <%= time_ago_in_words(agent.last_benchmark_date) %> ago
3939
(<%= agent.last_benchmark_date.to_fs(:short) %>)
@@ -42,8 +42,17 @@
4242
<span class="badge bg-warning ms-2">Stale</span>
4343
<% end %>
4444
</div>
45-
<% end %>
45+
<% if safe_can?(:expire_benchmarks, agent) %>
46+
<%= button_to "Expire Benchmarks",
47+
expire_benchmarks_agent_path(agent),
48+
method: :post,
49+
class: "btn btn-outline-warning btn-sm",
50+
data: { turbo_confirm: "This will delete all benchmarks and force the agent to re-benchmark on its next check-in. Continue?" } %>
51+
<% end %>
52+
</div>
53+
<% end %>
4654

55+
<% cache [agent.id, :benchmarks, agent.hashcat_benchmarks.maximum(:updated_at)] do %>
4756
<div class="table-responsive">
4857
<table class="table table-sm table-hover">
4958
<thead>

config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,9 @@
338338
get "system_health", to: "system_health#index", as: :system_health
339339

340340
resources :agents do
341+
member do
342+
post :expire_benchmarks
343+
end
341344
collection do
342345
get :cards
343346
end

0 commit comments

Comments
 (0)