First Basic Implementation of Remote Script Execution Functionality

This commit is contained in:
2025-09-03 19:17:05 -06:00
parent 3c67e3a996
commit fe18eed013
16 changed files with 2196 additions and 58 deletions

View File

@@ -0,0 +1,57 @@
import os
import subprocess
import sys
import platform
def run_powershell_script(script_path: str):
"""
Execute a PowerShell script with ExecutionPolicy Bypass.
Returns (returncode, stdout, stderr)
"""
if not script_path or not os.path.isfile(script_path):
raise FileNotFoundError(f"Script not found: {script_path}")
if not script_path.lower().endswith(".ps1"):
raise ValueError("run_powershell_script only accepts .ps1 files")
system = platform.system()
# Choose powershell binary
ps_bin = None
if system == "Windows":
# Prefer Windows PowerShell
ps_bin = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
if not os.path.isfile(ps_bin):
ps_bin = "powershell.exe"
else:
# PowerShell Core (pwsh) may exist cross-platform
ps_bin = "pwsh"
# Build command
# -ExecutionPolicy Bypass (Windows only), -NoProfile, -File "script"
cmd = [ps_bin]
if system == "Windows":
cmd += ["-ExecutionPolicy", "Bypass"]
cmd += ["-NoProfile", "-File", script_path]
# Hide window on Windows
creationflags = 0
startupinfo = None
if system == "Windows":
creationflags = 0x08000000 # CREATE_NO_WINDOW
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
creationflags=creationflags,
startupinfo=startupinfo,
)
out, err = proc.communicate()
return proc.returncode, out or "", err or ""

View File

@@ -1,6 +1,6 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_Details.js
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Paper,
Box,
@@ -15,8 +15,19 @@ import {
Button,
LinearProgress,
TableSortLabel,
TextField
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions
} from "@mui/material";
import Prism from "prismjs";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-powershell";
import "prismjs/components/prism-batch";
import "prismjs/themes/prism-okaidia.css";
import Editor from "react-simple-code-editor";
export default function DeviceDetails({ device, onBack }) {
const [tab, setTab] = useState(0);
@@ -26,6 +37,13 @@ export default function DeviceDetails({ device, onBack }) {
const [softwareOrder, setSoftwareOrder] = useState("asc");
const [softwareSearch, setSoftwareSearch] = useState("");
const [description, setDescription] = useState("");
const [historyRows, setHistoryRows] = useState([]);
const [historyOrderBy, setHistoryOrderBy] = useState("ran_at");
const [historyOrder, setHistoryOrder] = useState("desc");
const [outputOpen, setOutputOpen] = useState(false);
const [outputTitle, setOutputTitle] = useState("");
const [outputContent, setOutputContent] = useState("");
const [outputLang, setOutputLang] = useState("powershell");
// Snapshotted status for the lifetime of this page
const [lockedStatus, setLockedStatus] = useState(() => {
// Prefer status provided by the device list row if available
@@ -89,6 +107,21 @@ export default function DeviceDetails({ device, onBack }) {
load();
}, [device]);
const loadHistory = useCallback(async () => {
if (!device?.hostname) return;
try {
const resp = await fetch(`/api/device/activity/${encodeURIComponent(device.hostname)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setHistoryRows(data.history || []);
} catch (e) {
console.warn("Failed to load activity history", e);
setHistoryRows([]);
}
}, [device]);
useEffect(() => { loadHistory(); }, [loadHistory]);
const saveDescription = async () => {
if (!details.summary?.hostname) return;
try {
@@ -136,6 +169,20 @@ export default function DeviceDetails({ device, onBack }) {
return `${num.toFixed(1)} ${units[i]}`;
};
const formatTimestamp = (epochSec) => {
const ts = Number(epochSec || 0);
if (!ts) return "unknown";
const d = new Date(ts * 1000);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const yyyy = d.getFullYear();
let hh = d.getHours();
const ampm = hh >= 12 ? "PM" : "AM";
hh = hh % 12 || 12;
const min = String(d.getMinutes()).padStart(2, "0");
return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`;
};
const handleSoftwareSort = (col) => {
if (softwareOrderBy === col) {
setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc");
@@ -162,7 +209,15 @@ export default function DeviceDetails({ device, onBack }) {
const summaryItems = [
{ label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
{ label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" },
{ label: "Last User", value: summary.last_user || "unknown" },
{ label: "Last User", value: (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box component="span" sx={{
display: 'inline-block', width: 10, height: 10, borderRadius: 10,
bgcolor: agent?.collector_active ? '#00d18c' : '#ff4f4f'
}} />
<span>{summary.last_user || 'unknown'}</span>
</Box>
) },
{ label: "Internal IP", value: summary.internal_ip || "unknown" },
{ label: "External IP", value: summary.external_ip || "unknown" },
{ label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "unknown" },
@@ -469,12 +524,147 @@ export default function DeviceDetails({ device, onBack }) {
);
};
const jobStatusColor = (s) => {
const val = String(s || "").toLowerCase();
if (val === "running") return "#58a6ff"; // borealis blue
if (val === "success") return "#00d18c";
if (val === "failed") return "#ff4f4f";
return "#666";
};
const highlightCode = (code, lang) => {
try {
return Prism.highlight(code ?? "", Prism.languages[lang] || Prism.languages.markup, lang);
} catch {
return String(code || "");
}
};
const handleViewOutput = async (row, which) => {
try {
const resp = await fetch(`/api/device/activity/job/${row.id}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const lang = ((data.script_path || "").toLowerCase().endsWith(".ps1")) ? "powershell"
: ((data.script_path || "").toLowerCase().endsWith(".bat")) ? "batch"
: ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash"
: ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell";
setOutputLang(lang);
setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${data.script_name}`);
setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || ""));
setOutputOpen(true);
} catch (e) {
console.warn("Failed to load output", e);
}
};
const handleHistorySort = (col) => {
if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc");
else {
setHistoryOrderBy(col);
setHistoryOrder("asc");
}
};
const sortedHistory = useMemo(() => {
const dir = historyOrder === "asc" ? 1 : -1;
return [...historyRows].sort((a, b) => {
const A = a[historyOrderBy];
const B = b[historyOrderBy];
if (historyOrderBy === "ran_at") return ((A || 0) - (B || 0)) * dir;
return String(A ?? "").localeCompare(String(B ?? "")) * dir;
});
}, [historyRows, historyOrderBy, historyOrder]);
const renderHistory = () => (
<Box sx={{ maxHeight: 400, overflowY: "auto" }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sortDirection={historyOrderBy === "script_name" ? historyOrder : false}>
<TableSortLabel
active={historyOrderBy === "script_name"}
direction={historyOrderBy === "script_name" ? historyOrder : "asc"}
onClick={() => handleHistorySort("script_name")}
>
Script Executed
</TableSortLabel>
</TableCell>
<TableCell sortDirection={historyOrderBy === "ran_at" ? historyOrder : false}>
<TableSortLabel
active={historyOrderBy === "ran_at"}
direction={historyOrderBy === "ran_at" ? historyOrder : "asc"}
onClick={() => handleHistorySort("ran_at")}
>
Ran On
</TableSortLabel>
</TableCell>
<TableCell sortDirection={historyOrderBy === "status" ? historyOrder : false}>
<TableSortLabel
active={historyOrderBy === "status"}
direction={historyOrderBy === "status" ? historyOrder : "asc"}
onClick={() => handleHistorySort("status")}
>
Job Status
</TableSortLabel>
</TableCell>
<TableCell>
StdOut / StdErr
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedHistory.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.script_name}</TableCell>
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
<TableCell>
<Box sx={{
display: "inline-block",
px: 1.2,
py: 0.25,
borderRadius: 999,
bgcolor: jobStatusColor(r.status),
color: "#000",
fontWeight: 600,
fontSize: "12px"
}}>
{r.status}
</Box>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", gap: 1 }}>
{r.has_stdout ? (
<Button size="small" onClick={() => handleViewOutput(r, 'stdout')} sx={{ color: "#58a6ff", textTransform: "none", minWidth: 0, p: 0 }}>
StdOut
</Button>
) : null}
{r.has_stderr ? (
<Button size="small" onClick={() => handleViewOutput(r, 'stderr')} sx={{ color: "#ff4f4f", textTransform: "none", minWidth: 0, p: 0 }}>
StdErr
</Button>
) : null}
</Box>
</TableCell>
</TableRow>
))}
{sortedHistory.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: "#888" }}>No activity yet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
);
const tabs = [
{ label: "Summary", content: renderSummary() },
{ label: "Software", content: renderSoftware() },
{ label: "Memory", content: renderMemory() },
{ label: "Storage", content: renderStorage() },
{ label: "Network", content: renderNetwork() }
{ label: "Network", content: renderNetwork() },
{ label: "Activity History", content: renderHistory() }
];
// Use the snapshotted status so it stays static while on this page
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen);
@@ -514,6 +704,32 @@ export default function DeviceDetails({ device, onBack }) {
))}
</Tabs>
<Box sx={{ mt: 2 }}>{tabs[tab].content}</Box>
<Dialog open={outputOpen} onClose={() => setOutputOpen(false)} fullWidth maxWidth="md"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{outputTitle}</DialogTitle>
<DialogContent>
<Box sx={{ border: "1px solid #333", borderRadius: 1, bgcolor: "#1e1e1e", maxHeight: 500, overflow: "auto" }}>
<Editor
value={outputContent}
onValueChange={() => {}}
highlight={(code) => highlightCode(code, outputLang)}
padding={12}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
color: "#e6edf3",
minHeight: 200
}}
textareaProps={{ readOnly: true }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOutputOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
</DialogActions>
</Dialog>
</Paper>
);
}

View File

@@ -11,12 +11,15 @@ import {
TableHead,
TableRow,
TableSortLabel,
Checkbox,
Button,
IconButton,
Menu,
MenuItem
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { DeleteDeviceDialog } from "../Dialogs.jsx";
import QuickJob from "../Scheduling/Quick_Job.jsx";
function formatLastSeen(tsSec, offlineAfter = 120) {
if (!tsSec) return "unknown";
@@ -48,6 +51,8 @@ export default function DeviceList({ onSelectDevice }) {
const [menuAnchor, setMenuAnchor] = useState(null);
const [selected, setSelected] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [selectedHosts, setSelectedHosts] = useState(() => new Set());
const [quickJobOpen, setQuickJobOpen] = useState(false);
const fetchAgents = useCallback(async () => {
try {
@@ -117,19 +122,65 @@ export default function DeviceList({ onSelectDevice }) {
setSelected(null);
};
const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedHosts.has(r.hostname));
const isIndeterminate = selectedHosts.size > 0 && !isAllChecked;
const toggleAll = (e) => {
const checked = e.target.checked;
setSelectedHosts((prev) => {
const next = new Set(prev);
if (checked) {
sorted.forEach((r) => next.add(r.hostname));
} else {
next.clear();
}
return next;
});
};
const toggleOne = (hostname) => (e) => {
const checked = e.target.checked;
setSelectedHosts((prev) => {
const next = new Set(prev);
if (checked) next.add(hostname);
else next.delete(hostname);
return next;
});
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box sx={{ p: 2, pb: 1 }}>
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
Devices
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
Devices connected to Borealis via Agent and their last check-ins.
</Typography>
<Box>
<Button
variant="outlined"
size="small"
disabled={selectedHosts.size === 0}
onClick={() => setQuickJobOpen(true)}
sx={{
mr: 1,
color: selectedHosts.size === 0 ? "#666" : "#58a6ff",
borderColor: selectedHosts.size === 0 ? "#333" : "#58a6ff",
textTransform: "none"
}}
>
Quick Job
</Button>
</Box>
</Box>
<Table size="small" sx={{ minWidth: 680 }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={isIndeterminate}
checked={isAllChecked}
onChange={toggleAll}
sx={{ color: "#777" }}
/>
</TableCell>
<TableCell sortDirection={orderBy === "status" ? order : false}>
<TableSortLabel
active={orderBy === "status"}
@@ -172,6 +223,13 @@ export default function DeviceList({ onSelectDevice }) {
<TableBody>
{sorted.map((r, i) => (
<TableRow key={r.id || i} hover>
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedHosts.has(r.hostname)}
onChange={toggleOne(r.hostname)}
sx={{ color: "#777" }}
/>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box
@@ -218,7 +276,7 @@ export default function DeviceList({ onSelectDevice }) {
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ color: "#888" }}>
<TableCell colSpan={6} sx={{ color: "#888" }}>
No agents connected.
</TableCell>
</TableRow>
@@ -238,6 +296,14 @@ export default function DeviceList({ onSelectDevice }) {
onCancel={() => setConfirmOpen(false)}
onConfirm={handleDelete}
/>
{quickJobOpen && (
<QuickJob
open={quickJobOpen}
onClose={() => setQuickJobOpen(false)}
hostnames={[...selectedHosts]}
/>
)}
</Paper>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useEffect, useMemo, useState, useCallback } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Paper,
FormControlLabel,
Checkbox
} from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
function buildTree(scripts, folders) {
const map = {};
const rootNode = {
id: "root",
label: "Scripts",
path: "",
isFolder: true,
children: []
};
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(scripts || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? s.file_name : part,
path,
isFolder: !isFile,
fileName: s.file_name,
script: isFile ? s : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root: [rootNode], map };
}
export default function QuickJob({ open, onClose, hostnames = [] }) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [selectedPath, setSelectedPath] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
const loadTree = useCallback(async () => {
try {
const resp = await fetch("/api/scripts/list");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.scripts || [], data.folders || []);
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load scripts:", err);
setTree([]);
setNodeMap({});
}
}, []);
useEffect(() => {
if (open) {
setSelectedPath("");
setError("");
loadTree();
}
}, [open, loadTree]);
const renderNodes = (nodes = []) =>
nodes.map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{n.isFolder ? (
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
) : (
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
)}
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length ? renderNodes(n.children) : null}
</TreeItem>
));
const onItemSelect = (_e, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder) {
setSelectedPath(node.path);
setError("");
}
};
const onRun = async () => {
if (!selectedPath) {
setError("Please choose a script to run.");
return;
}
setRunning(true);
setError("");
try {
const resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script_path: selectedPath, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setRunning(false);
}
};
return (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Quick Job</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
Select a script to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
</Typography>
<Box sx={{ display: "flex", gap: 2 }}>
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
{tree.length ? renderNodes(tree) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
No scripts found.
</Typography>
)}
</SimpleTreeView>
</Paper>
<Box sx={{ width: 320 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
{selectedPath || "No script selected"}
</Typography>
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
/>
<Typography variant="caption" sx={{ color: "#888" }}>
Unchecked = run as SYSTEM (requires agent service)
</Typography>
</Box>
{error && (
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onRun} disabled={running || !selectedPath}
sx={{ color: running || !selectedPath ? "#666" : "#58a6ff" }}
>
Run
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -19,6 +19,7 @@ import io
# Borealis Python API Endpoints
from Python_API_Endpoints.ocr_engines import run_ocr_on_base64
from Python_API_Endpoints.script_engines import run_powershell_script
# ---------------------------------------------
# Flask + WebSocket Server Configuration
@@ -659,6 +660,22 @@ def init_db():
cur.execute(
"CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT)"
)
# Activity history table for script/job runs
cur.execute(
"""
CREATE TABLE IF NOT EXISTS activity_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT,
script_path TEXT,
script_name TEXT,
script_type TEXT,
ran_at INTEGER,
status TEXT,
stdout TEXT,
stderr TEXT
)
"""
)
conn.commit()
conn.close()
@@ -738,10 +755,20 @@ load_agents_from_db()
@app.route("/api/agents")
def get_agents():
"""
Return a dict keyed by agent_id with hostname, os, last_seen, status.
"""
return jsonify(registered_agents)
"""Return agents with collector activity indicator."""
now = time.time()
out = {}
for aid, info in (registered_agents or {}).items():
# Hide script-execution agents from the public list
if aid and isinstance(aid, str) and aid.lower().endswith('-script'):
continue
if info.get('is_script_agent'):
continue
d = dict(info)
ts = d.get('collector_active_ts') or 0
d['collector_active'] = bool(ts and (now - float(ts) < 130))
out[aid] = d
return jsonify(out)
@app.route("/api/agent/details", methods=["POST"])
@@ -840,6 +867,230 @@ def set_device_description(hostname: str):
return jsonify({"error": str(e)}), 500
# ---------------------------------------------
# Quick Job Execution + Activity History
# ---------------------------------------------
def _detect_script_type(fn: str) -> str:
fn = (fn or "").lower()
if fn.endswith(".yml"):
return "ansible"
if fn.endswith(".ps1"):
return "powershell"
if fn.endswith(".bat"):
return "batch"
if fn.endswith(".sh"):
return "bash"
return "unknown"
def _safe_filename(rel_path: str) -> str:
try:
return os.path.basename(rel_path or "")
except Exception:
return rel_path or ""
@app.route("/api/scripts/quick_run", methods=["POST"])
def scripts_quick_run():
"""Queue a Quick Job to agents via WebSocket and record Running status.
Payload: { script_path: str, hostnames: [str], run_mode?: 'current_user'|'admin'|'system', admin_user?, admin_pass? }
"""
data = request.get_json(silent=True) or {}
rel_path = (data.get("script_path") or "").strip()
hostnames = data.get("hostnames") or []
run_mode = (data.get("run_mode") or "system").strip().lower()
admin_user = ""
admin_pass = ""
if not rel_path or not isinstance(hostnames, list) or not hostnames:
return jsonify({"error": "Missing script_path or hostnames[]"}), 400
scripts_root = _scripts_root()
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
if not abs_path.startswith(scripts_root) or not os.path.isfile(abs_path):
return jsonify({"error": "Script not found"}), 404
script_type = _detect_script_type(abs_path)
if script_type != "powershell":
return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400
try:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception as e:
return jsonify({"error": f"Failed to read script: {e}"}), 500
now = int(time.time())
results = []
for host in hostnames:
job_id = None
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"""
INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr)
VALUES(?,?,?,?,?,?,?,?)
""",
(
host,
rel_path.replace(os.sep, "/"),
_safe_filename(rel_path),
script_type,
now,
"Running",
"",
"",
),
)
job_id = cur.lastrowid
conn.commit()
conn.close()
except Exception as db_err:
return jsonify({"error": f"DB insert failed: {db_err}"}), 500
payload = {
"job_id": job_id,
"target_hostname": host,
"script_type": script_type,
"script_name": _safe_filename(rel_path),
"script_path": rel_path.replace(os.sep, "/"),
"script_content": content,
"run_mode": run_mode,
"admin_user": admin_user,
"admin_pass": admin_pass,
}
# Broadcast to all connected clients; no broadcast kw in python-socketio v5
socketio.emit("quick_job_run", payload)
results.append({"hostname": host, "job_id": job_id, "status": "Running"})
return jsonify({"results": results})
@app.route("/api/device/activity/<hostname>", methods=["GET"])
def device_activity(hostname: str):
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"SELECT id, script_name, script_path, script_type, ran_at, status, LENGTH(stdout), LENGTH(stderr) FROM activity_history WHERE hostname = ? ORDER BY ran_at DESC, id DESC",
(hostname,),
)
rows = cur.fetchall()
conn.close()
out = []
for (jid, name, path, stype, ran_at, status, so_len, se_len) in rows:
out.append({
"id": jid,
"script_name": name,
"script_path": path,
"script_type": stype,
"ran_at": ran_at,
"status": status,
"has_stdout": bool(so_len or 0),
"has_stderr": bool(se_len or 0),
})
return jsonify({"history": out})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/device/activity/job/<int:job_id>", methods=["GET"])
def device_activity_job(job_id: int):
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"SELECT id, hostname, script_name, script_path, script_type, ran_at, status, stdout, stderr FROM activity_history WHERE id = ?",
(job_id,),
)
row = cur.fetchone()
conn.close()
if not row:
return jsonify({"error": "Not found"}), 404
(jid, hostname, name, path, stype, ran_at, status, stdout, stderr) = row
return jsonify({
"id": jid,
"hostname": hostname,
"script_name": name,
"script_path": path,
"script_type": stype,
"ran_at": ran_at,
"status": status,
"stdout": stdout or "",
"stderr": stderr or "",
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@socketio.on("quick_job_result")
def handle_quick_job_result(data):
"""Agent reports back stdout/stderr/status for a job."""
try:
job_id = int(data.get("job_id"))
except Exception:
return
status = (data.get("status") or "").strip() or "Failed"
stdout = data.get("stdout") or ""
stderr = data.get("stderr") or ""
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"UPDATE activity_history SET status=?, stdout=?, stderr=? WHERE id=?",
(status, stdout, stderr, job_id),
)
conn.commit()
conn.close()
except Exception as e:
print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}")
@socketio.on("collector_status")
def handle_collector_status(data):
"""Collector agent reports activity and optional last_user."""
agent_id = (data or {}).get('agent_id')
hostname = (data or {}).get('hostname')
active = bool((data or {}).get('active'))
last_user = (data or {}).get('last_user')
if not agent_id:
return
rec = registered_agents.setdefault(agent_id, {})
rec['agent_id'] = agent_id
if hostname:
rec['hostname'] = hostname
if active:
rec['collector_active_ts'] = time.time()
if last_user and (hostname or rec.get('hostname')):
try:
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute(
"SELECT details, description FROM device_details WHERE hostname = ?",
(hostname or rec.get('hostname'),),
)
row = cur.fetchone()
details = {}
if row and row[0]:
try:
details = json.loads(row[0])
except Exception:
details = {}
summary = details.get('summary') or {}
summary['last_user'] = last_user
details['summary'] = summary
cur.execute(
"REPLACE INTO device_details (hostname, description, details) VALUES (?, COALESCE((SELECT description FROM device_details WHERE hostname=?), ''), ?)",
((hostname or rec.get('hostname')), (hostname or rec.get('hostname')), json.dumps(details))
)
conn.commit()
conn.close()
except Exception:
pass
@app.route("/api/agent/<agent_id>", methods=["DELETE"])
def delete_agent(agent_id: str):
"""Remove an agent from the registry and database."""
@@ -979,7 +1230,8 @@ def receive_screenshot_task(data):
"timestamp": time.time()
}
emit("agent_screenshot_task", data, broadcast=True)
# Relay to all connected clients; use server-level emit
socketio.emit("agent_screenshot_task", data)
@socketio.on("connect_agent")
def connect_agent(data):
@@ -998,6 +1250,12 @@ def connect_agent(data):
rec["agent_operating_system"] = rec.get("agent_operating_system", "-")
rec["last_seen"] = int(time.time())
rec["status"] = "provisioned" if agent_id in agent_configurations else "orphaned"
# Flag script agents so they can be filtered out elsewhere if desired
try:
if isinstance(agent_id, str) and agent_id.lower().endswith('-script'):
rec['is_script_agent'] = True
except Exception:
pass
# If we already know the hostname for this agent, persist last_seen so it
# can be restored after server restarts.
try:
@@ -1056,7 +1314,8 @@ def receive_screenshot(data):
"image_base64": image,
"timestamp": time.time()
}
emit("new_screenshot", {"agent_id": agent_id, "image_base64": image}, broadcast=True)
# Broadcast to all clients; use server-level emit
socketio.emit("new_screenshot", {"agent_id": agent_id, "image_base64": image})
@socketio.on("disconnect")
def on_disconnect():
@@ -1076,21 +1335,24 @@ def receive_macro_status(data):
}
"""
print(f"[Macro Status] Agent {data.get('agent_id')} Node {data.get('node_id')} Success: {data.get('success')} Msg: {data.get('message')}")
emit("macro_status", data, broadcast=True)
# Broadcast to all; use server-level emit for v5 API
socketio.emit("macro_status", data)
@socketio.on("list_agent_windows")
def handle_list_agent_windows(data):
"""
Forwards list_agent_windows event to all agents (or filter for a specific agent_id).
"""
emit("list_agent_windows", data, broadcast=True)
# Forward to all agents/clients
socketio.emit("list_agent_windows", data)
@socketio.on("agent_window_list")
def handle_agent_window_list(data):
"""
Relay the list of windows from the agent back to all connected clients.
"""
emit("agent_window_list", data, broadcast=True)
# Relay the list to all interested clients
socketio.emit("agent_window_list", data)
# ---------------------------------------------
# Server Launch