Add fleet registry removal with confirm modal and agent_removed WS.
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
Operators can remove machines from Crucible and dashboard rosters with honest messaging that deletion is registry-only; bulk select, oath ledger entries, and Go/Vitest coverage included.
This commit is contained in:
@@ -663,6 +663,7 @@ func EstimateXMRPerDay(hashrate float64) map[string]interface{} {
|
||||
|
||||
// DeleteAgent removes an agent record from the database.
|
||||
// If the agent is currently online it is also disconnected (kicked).
|
||||
// This is fleet-registry removal only — it does not uninstall the agent binary on the host.
|
||||
func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
@@ -674,20 +675,29 @@ func (f *FleetHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
|
||||
f.ws.RemoveAgent(id)
|
||||
}
|
||||
if err := f.db.DeleteAgent(id); err != nil {
|
||||
if removed, err := f.db.DeleteAgent(id); err != nil {
|
||||
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
} else if !removed {
|
||||
http.Error(w, "agent not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Clear stale in-memory alerts so the machine stops showing up in the
|
||||
// dashboard alert banner after deletion.
|
||||
if f.alerts != nil {
|
||||
f.alerts.ClearAgent(id)
|
||||
}
|
||||
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
|
||||
AuthUsername(r), db.OathAgentRemoved, id, "", db.OathOutcomeSuccess,
|
||||
map[string]string{"scope": "fleet_registry"},
|
||||
map[string]string{"agent_id": id},
|
||||
)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// BulkDeleteAgents deletes multiple agents from the database in one call.
|
||||
// Fleet-registry removal only — does not uninstall agent binaries on hosts.
|
||||
func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
@@ -702,11 +712,16 @@ func (f *FleetHandler) BulkDeleteAgents(w http.ResponseWriter, r *http.Request)
|
||||
_ = f.ws.SendToAgent(id, Message{Type: "disconnect", Payload: mustMarshalFleet(map[string]string{"reason": "deleted from roster"})})
|
||||
f.ws.RemoveAgent(id)
|
||||
}
|
||||
if err := f.db.DeleteAgent(id); err == nil {
|
||||
if removed, err := f.db.DeleteAgent(id); err == nil && removed {
|
||||
deleted++
|
||||
if f.alerts != nil {
|
||||
f.alerts.ClearAgent(id)
|
||||
}
|
||||
_ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record(
|
||||
AuthUsername(r), db.OathAgentRemoved, id, "", db.OathOutcomeSuccess,
|
||||
map[string]string{"scope": "fleet_registry", "bulk": "true"},
|
||||
map[string]string{"agent_id": id},
|
||||
)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -78,6 +78,8 @@ func fleetChiRoute(method, pattern string, handler http.HandlerFunc) http.Handle
|
||||
r.Post(pattern, handler)
|
||||
case http.MethodPut:
|
||||
r.Put(pattern, handler)
|
||||
case http.MethodDelete:
|
||||
r.Delete(pattern, handler)
|
||||
default:
|
||||
panic("unsupported method " + method)
|
||||
}
|
||||
@@ -989,3 +991,134 @@ func TestFleetPostAgentCommandEmptyAgentID(t *testing.T) {
|
||||
t.Fatalf("expected 400 for empty id, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func readDashboardWSMessage(t *testing.T, conn *websocket.Conn, wantType string) Message {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var msg Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
t.Fatalf("read %s: %v", wantType, err)
|
||||
}
|
||||
if msg.Type == wantType {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", wantType)
|
||||
return Message{}
|
||||
}
|
||||
|
||||
func TestFleetDeleteAgentSuccess(t *testing.T) {
|
||||
fh, database, ws, _ := newTestFleetHandler(t)
|
||||
agent := &models.Agent{ID: "del-agent-1", Name: "rig", Status: "offline", LastSeen: time.Now()}
|
||||
if err := database.UpsertAgent(agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dashConn := connectTestDashboard(t, ws)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/agents/del-agent-1", nil)
|
||||
fleetChiRoute(http.MethodDelete, "/agents/{id}", fh.DeleteAgent).ServeHTTP(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["success"] != true {
|
||||
t.Fatalf("unexpected body: %v", body)
|
||||
}
|
||||
if _, err := database.GetAgent("del-agent-1"); err == nil {
|
||||
t.Fatal("expected agent removed from database")
|
||||
}
|
||||
|
||||
removed := readDashboardWSMessage(t, dashConn, "agent_removed")
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(removed.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["agent_id"] != "del-agent-1" {
|
||||
t.Fatalf("agent_id = %q", payload["agent_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetDeleteAgentEmptyID(t *testing.T) {
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
fh.DeleteAgent(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetDeleteAgentDisconnectsOnline(t *testing.T) {
|
||||
fh, database, ws, _ := newTestFleetHandler(t)
|
||||
agentID := "del-online"
|
||||
connectTestAgent(t, ws, agentID)
|
||||
if !ws.isAgentConnected(agentID) {
|
||||
t.Fatal("agent should be connected")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/agents/"+agentID, nil)
|
||||
fleetChiRoute(http.MethodDelete, "/agents/{id}", fh.DeleteAgent).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ws.isAgentConnected(agentID) {
|
||||
t.Fatal("agent should be disconnected after delete")
|
||||
}
|
||||
if _, err := database.GetAgent(agentID); err == nil {
|
||||
t.Fatal("expected agent removed from database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetBulkDeleteAgents(t *testing.T) {
|
||||
fh, database, ws, _ := newTestFleetHandler(t)
|
||||
for _, id := range []string{"bulk-del-a", "bulk-del-b", "bulk-del-missing"} {
|
||||
if id == "bulk-del-missing" {
|
||||
continue
|
||||
}
|
||||
agent := &models.Agent{ID: id, Name: id, Status: "offline", LastSeen: time.Now()}
|
||||
if err := database.UpsertAgent(agent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
connectTestAgent(t, ws, "bulk-del-a")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-delete",
|
||||
strings.NewReader(`{"ids":["bulk-del-a","bulk-del-b","bulk-del-missing"]}`))
|
||||
fh.BulkDeleteAgents(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["deleted"].(float64) != 2 {
|
||||
t.Fatalf("deleted count = %v", body["deleted"])
|
||||
}
|
||||
if _, err := database.GetAgent("bulk-del-a"); err == nil {
|
||||
t.Fatal("bulk-del-a should be gone")
|
||||
}
|
||||
if _, err := database.GetAgent("bulk-del-b"); err == nil {
|
||||
t.Fatal("bulk-del-b should be gone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetBulkDeleteAgentsRequiresIDs(t *testing.T) {
|
||||
fh, _, _, _ := newTestFleetHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-delete", strings.NewReader(`{"ids":[]}`))
|
||||
fh.BulkDeleteAgents(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2043,7 +2043,7 @@ func (h *WSHub) SendToAgent(agentID string, msg Message) error {
|
||||
}
|
||||
|
||||
// RemoveAgent forcibly disconnects an agent and removes it from the live map.
|
||||
// It then broadcasts agent_deleted to all dashboard clients so the UI removes
|
||||
// It then broadcasts agent_removed to all dashboard clients so the UI removes
|
||||
// the agent immediately without waiting for the disconnect goroutine to fire.
|
||||
func (h *WSHub) RemoveAgent(agentID string) {
|
||||
h.mu.Lock()
|
||||
@@ -2062,9 +2062,9 @@ func (h *WSHub) RemoveAgent(agentID string) {
|
||||
ac.Conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
// Broadcast deletion so every connected dashboard removes the agent immediately.
|
||||
// Broadcast removal so every connected dashboard drops the agent immediately.
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "agent_deleted",
|
||||
Type: "agent_removed",
|
||||
Payload: mustMarshal(map[string]string{"agent_id": agentID}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
OathSpreadDiscoveredHost = "spread_discovered_host"
|
||||
OathStrainHospice = "strain_hospice"
|
||||
OathReconScan = "recon_scan"
|
||||
OathAgentRemoved = "agent_removed"
|
||||
)
|
||||
|
||||
// Oath outcomes.
|
||||
|
||||
@@ -389,9 +389,13 @@ func (d *Database) MarkStaleAgentsOffline(olderThan time.Duration) (int, error)
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAgent(id string) error {
|
||||
_, err := d.Exec("DELETE FROM agents WHERE id = ?", id)
|
||||
return err
|
||||
func (d *Database) DeleteAgent(id string) (bool, error) {
|
||||
res, err := d.Exec("DELETE FROM agents WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// FindAgentByMAC returns the agent ID for an agent whose MAC address matches.
|
||||
|
||||
Reference in New Issue
Block a user