Add tactical fleet remote actions and agent command channel.

Extend remote commands with exec, PowerShell, file transfer, and fleet-wide broadcast; refresh AgentRemoteActions UI and WebSocket handling.
This commit is contained in:
drjones
2026-05-27 21:24:57 -07:00
parent b10d353a8b
commit 830c755235
6 changed files with 477 additions and 234 deletions

View File

@@ -42,21 +42,10 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
return c.Conn.WriteJSON(v)
}
type DashboardConnection struct {
Conn *websocket.Conn
mu sync.Mutex
}
func (c *DashboardConnection) WriteMessage(messageType int, data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.Conn.WriteMessage(messageType, data)
}
type WSHub struct {
db *db.Database
agents map[string]*AgentConnection
dashboards map[string]*DashboardConnection
dashboards map[string]*websocket.Conn
poolManager *pool.Manager
defaultPool pool.Config
aiHandler *AIHandler
@@ -71,7 +60,7 @@ func NewWSHub(database *db.Database) *WSHub {
return &WSHub{
db: database,
agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*DashboardConnection),
dashboards: make(map[string]*websocket.Conn),
agentConfigs: make(map[string]AgentForgeConfig),
agentLogs: make(map[string]string),
pingIntervalSec: 30,
@@ -179,13 +168,6 @@ func (h *WSHub) agentPoolConfig(agentID string) pool.Config {
return poolCfg
}
func (h *WSHub) BroadcastServerLog(line string) {
h.broadcastDashboard(Message{
Type: "server_log",
Payload: mustMarshal(map[string]string{"line": strings.TrimSpace(line)}),
})
}
func (h *WSHub) getAgentConn(agentID string) *AgentConnection {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -404,79 +386,76 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
share.Timestamp = time.Now()
share.Accepted = false
// Process share asynchronously to prevent blocking the WebSocket read loop
go func(s models.Share, aID string) {
shareID, err := h.db.InsertShare(&s)
if err != nil {
log.Printf("Failed to insert share: %v", err)
return
shareID, err := h.db.InsertShare(&share)
if err != nil {
log.Printf("Failed to insert share: %v", err)
continue
}
sendShareResult := func(accepted bool, errMsg string) {
share.Accepted = accepted
share.Error = errMsg
if err := h.db.UpdateShareResult(shareID, accepted, errMsg); err != nil {
log.Printf("Failed to update share result: %v", err)
}
if h.serverPolicySnapshot().LogShareSubmissions {
log.Printf("[WS] Share agent=%s job=%s accepted=%v err=%q", agentID, share.JobID, accepted, errMsg)
}
sendShareResult := func(accepted bool, errMsg string) {
s.Accepted = accepted
s.Error = errMsg
if err := h.db.UpdateShareResult(shareID, accepted, errMsg); err != nil {
log.Printf("Failed to update share result: %v", err)
agentConn := h.getAgentConn(agentID)
if agentConn != nil {
result := map[string]interface{}{
"job_id": share.JobID,
"accepted": accepted,
}
if h.serverPolicySnapshot().LogShareSubmissions {
log.Printf("[WS] Share agent=%s job=%s accepted=%v err=%q", aID, s.JobID, accepted, errMsg)
if errMsg != "" {
result["error"] = errMsg
}
agentConn := h.getAgentConn(aID)
if agentConn != nil {
result := map[string]interface{}{
"job_id": s.JobID,
"accepted": accepted,
}
if errMsg != "" {
result["error"] = errMsg
}
_ = agentConn.SendJSON(Message{Type: "share_result", Payload: mustMarshal(result)})
}
h.broadcastDashboard(Message{
Type: "new_share",
Payload: mustMarshal(map[string]interface{}{
"id": shareID,
"agent_id": aID,
"job_id": s.JobID,
"accepted": accepted,
"hash": s.Hash,
"nonce": s.Nonce,
"error": errMsg,
"timestamp": s.Timestamp,
}),
})
_ = agentConn.SendJSON(Message{Type: "share_result", Payload: mustMarshal(result)})
}
if h.poolManager == nil {
sendShareResult(false, "pool manager not configured")
return
}
h.broadcastDashboard(Message{
Type: "new_share",
Payload: mustMarshal(map[string]interface{}{
"id": shareID,
"agent_id": agentID,
"job_id": share.JobID,
"accepted": accepted,
"hash": share.Hash,
"nonce": share.Nonce,
"error": errMsg,
"timestamp": share.Timestamp,
}),
})
}
poolCfg := h.agentPoolConfig(aID)
proxy := h.poolManager.GetPool(&poolCfg)
if proxy == nil {
if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil {
proxy = p
} else {
sendShareResult(false, "pool not connected: "+err.Error())
return
}
}
if h.poolManager == nil {
sendShareResult(false, "pool manager not configured")
continue
}
if !proxy.IsConnected() {
sendShareResult(false, "pool not connected")
return
poolCfg := h.agentPoolConfig(agentID)
proxy := h.poolManager.GetPool(&poolCfg)
if proxy == nil {
if p, err := h.poolManager.EnsurePool(&poolCfg); err == nil {
proxy = p
} else {
sendShareResult(false, "pool not connected: "+err.Error())
continue
}
}
wallet := poolCfg.Wallet
if wallet == "" {
wallet = h.defaultPool.Wallet
}
if !proxy.IsConnected() {
sendShareResult(false, "pool not connected")
continue
}
proxy.SubmitShare(aID, wallet, s.JobID, s.Nonce, s.Hash, sendShareResult)
}(share, agentID)
wallet := poolCfg.Wallet
if wallet == "" {
wallet = h.defaultPool.Wallet
}
proxy.SubmitShare(agentID, wallet, share.JobID, share.Nonce, share.Hash, sendShareResult)
case "get_job":
var proxy *pool.Proxy
@@ -535,9 +514,8 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
}
dashID := uuid.New().String()
dashConn := &DashboardConnection{Conn: conn}
h.mu.Lock()
h.dashboards[dashID] = dashConn
h.dashboards[dashID] = conn
h.mu.Unlock()
defer func() {
@@ -576,19 +554,17 @@ func (h *WSHub) broadcastDashboard(msg Message) {
return
}
for id, dashConn := range h.dashboards {
go func(dashID string, dc *DashboardConnection) {
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
// Use fmt.Printf to avoid infinite loop with the global log interceptor
fmt.Printf("Failed to send to dashboard %s: %v\n", dashID, err)
dc.Conn.Close()
go func() {
h.mu.Lock()
delete(h.dashboards, dashID)
h.mu.Unlock()
}()
}
}(id, dashConn)
for id, conn := range h.dashboards {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to send to dashboard %s: %v", id, err)
conn.Close()
id := id
go func() {
h.mu.Lock()
delete(h.dashboards, id)
h.mu.Unlock()
}()
}
}
}
@@ -603,11 +579,9 @@ func (h *WSHub) BroadcastToAgents(msg Message) {
defer h.mu.RUnlock()
for id, agent := range h.agents {
go func(a *AgentConnection, agentID string) {
if err := a.SendJSON(msg); err != nil {
fmt.Printf("Failed to send to agent %s: %v\n", agentID, err)
}
}(agent, id)
if err := agent.SendJSON(msg); err != nil {
log.Printf("Failed to send to agent %s: %v", id, err)
}
}
}
@@ -629,6 +603,15 @@ func (h *WSHub) SendAgentCommand(agentID, action string, args map[string]interfa
return h.SendToAgent(agentID, Message{Type: "command", Payload: mustMarshal(payload)})
}
// BroadcastAgentCommand sends a remote command to all connected agents.
func (h *WSHub) BroadcastAgentCommand(action string, args map[string]interface{}) {
payload := map[string]interface{}{"action": action}
for k, v := range args {
payload[k] = v
}
h.BroadcastToAgents(Message{Type: "command", Payload: mustMarshal(payload)})
}
func (h *WSHub) GetAgentLog(agentID string) string {
h.mu.RLock()
defer h.mu.RUnlock()