Implemented Remote Agent Updater Script

This commit is contained in:
2025-10-04 21:13:36 -06:00
parent a50af1eccc
commit 13c571cc8e
4 changed files with 17 additions and 232 deletions

View File

@@ -0,0 +1,16 @@
{
"version": 1,
"name": "Remote Agent Update [WIN]",
"description": "Reaches out to the remote Borealis agent and triggers an automatic unattended update from the Github repository.",
"category": "script",
"type": "powershell",
"script": "W0NtZGxldEJpbmRpbmcoKV0KcGFyYW0oCiAgICBbUGFyYW1ldGVyKCldCiAgICBbc3RyaW5nXSRUYXNrTmFtZSA9ICJCb3JlYWxpcyBBZ2VudCIsCgogICAgW1BhcmFtZXRlcigpXQogICAgW3N0cmluZ10kVGFza1BhdGgKKQoKJHRhc2tQYXJhbXMgPSBAeyBUYXNrTmFtZSA9ICRUYXNrTmFtZTsgRXJyb3JBY3Rpb24gPSAnU3RvcCcgfQppZiAoJFRhc2tQYXRoKSB7CiAgICAkdGFza1BhcmFtcy5UYXNrUGF0aCA9ICRUYXNrUGF0aAp9Cgp0cnkgewogICAgJHRhc2sgPSBHZXQtU2NoZWR1bGVkVGFzayBAdGFza1BhcmFtcwp9IGNhdGNoIHsKICAgIHRocm93ICJTY2hlZHVsZWQgdGFzayAnJFRhc2tOYW1lJyB3YXMgbm90IGZvdW5kLiIgCn0KCiRleGVjQWN0aW9uID0gJHRhc2suQWN0aW9ucyB8IFdoZXJlLU9iamVjdCB7ICRfLkNpbUNsYXNzLkNpbUNsYXNzTmFtZSAtZXEgJ01TRlRfVGFza0V4ZWNBY3Rpb24nIH0gfCBTZWxlY3QtT2JqZWN0IC1GaXJzdCAxCmlmICgtbm90ICRleGVjQWN0aW9uKSB7CiAgICB0aHJvdyAiU2NoZWR1bGVkIHRhc2sgJyRUYXNrTmFtZScgZG9lcyBub3QgY29udGFpbiBhbiBleGVjdXRhYmxlIGFjdGlvbi4iCn0KCiR3b3JraW5nRGlyZWN0b3J5ID0gJGV4ZWNBY3Rpb24uV29ya2luZ0RpcmVjdG9yeQppZiAoW3N0cmluZ106OklzTnVsbE9yV2hpdGVTcGFjZSgkd29ya2luZ0RpcmVjdG9yeSkpIHsKICAgICRjYW5kaWRhdGUgPSBTcGxpdC1QYXRoIC1QYXRoICRleGVjQWN0aW9uLkV4ZWN1dGUgLVBhcmVudAogICAgaWYgKFtzdHJpbmddOjpJc051bGxPcldoaXRlU3BhY2UoJGNhbmRpZGF0ZSkpIHsKICAgICAgICB0aHJvdyAiVW5hYmxlIHRvIGRldGVybWluZSB0aGUgd29ya2luZyBkaXJlY3RvcnkgZm9yIHNjaGVkdWxlZCB0YXNrICckVGFza05hbWUnLiIKICAgIH0KICAgICR3b3JraW5nRGlyZWN0b3J5ID0gJGNhbmRpZGF0ZQp9Cgp0cnkgewogICAgJGFnZW50Um9vdCA9IFJlc29sdmUtUGF0aCAtUGF0aCAkd29ya2luZ0RpcmVjdG9yeSAtRXJyb3JBY3Rpb24gU3RvcAp9IGNhdGNoIHsKICAgIHRocm93ICJUaGUgd29ya2luZyBkaXJlY3RvcnkgJyR3b3JraW5nRGlyZWN0b3J5JyBmb3Igc2NoZWR1bGVkIHRhc2sgJyRUYXNrTmFtZScgZG9lcyBub3QgZXhpc3QuIgp9Cgp0cnkgewogICAgJHJlcG9Sb290ID0gUmVzb2x2ZS1QYXRoIC1QYXRoIChKb2luLVBhdGggJGFnZW50Um9vdCAnLi5cLi4nKSAtRXJyb3JBY3Rpb24gU3RvcAp9IGNhdGNoIHsKICAgIHRocm93ICJVbmFibGUgdG8gcmVzb2x2ZSB0aGUgQm9yZWFsaXMgcmVwb3NpdG9yeSByb290IGZyb20gJyRhZ2VudFJvb3QnLiIKfQoKJHVwZGF0ZVNjcmlwdCA9IEpvaW4tUGF0aCAkcmVwb1Jvb3QgJ0JvcmVhbGlzLnBzMScKaWYgKC1ub3QgKFRlc3QtUGF0aCAtUGF0aCAkdXBkYXRlU2NyaXB0IC1QYXRoVHlwZSBMZWFmKSkgewogICAgdGhyb3cgIkJvcmVhbGlzLnBzMSB3YXMgbm90IGZvdW5kIGF0ICckdXBkYXRlU2NyaXB0Jy4iCn0KCldyaXRlLVZlcmJvc2UgIlJlc29sdmVkIHNjaGVkdWxlZCB0YXNrIHdvcmtpbmcgZGlyZWN0b3J5OiAkYWdlbnRSb290IgpXcml0ZS1WZXJib3NlICJCb3JlYWxpcyByZXBvc2l0b3J5IHJvb3Q6ICRyZXBvUm9vdCIKV3JpdGUtVmVyYm9zZSAiSW52b2tpbmcgJyR1cGRhdGVTY3JpcHQgLVNpbGVudFVwZGF0ZSciCgokdXBkYXRlU3VjY2VlZGVkID0gJGZhbHNlCgpQdXNoLUxvY2F0aW9uIC1QYXRoICRyZXBvUm9vdAp0cnkgewogICAgJiAkdXBkYXRlU2NyaXB0IC1TaWxlbnRVcGRhdGUKICAgICR1cGRhdGVTdWNjZWVkZWQgPSAkPwp9IGZpbmFsbHkgewogICAgUG9wLUxvY2F0aW9uCn0KCmlmICgtbm90ICR1cGRhdGVTdWNjZWVkZWQpIHsKICAgIHRocm93ICJCb3JlYWxpcy5wczEgLVNpbGVudFVwZGF0ZSBmYWlsZWQuIgp9CgpXcml0ZS1WZXJib3NlICJCb3JlYWxpcy5wczEgLVNpbGVudFVwZGF0ZSBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5LiI=",
"timeout_seconds": 3600,
"sites": {
"mode": "all",
"values": []
},
"variables": [],
"files": [],
"script_encoding": "base64"
}

View File

@@ -39,54 +39,6 @@ def _find_borealis_root() -> Optional[str]:
return None
def _launch_silent_update_task():
if os.name != 'nt':
raise RuntimeError('Silent update is supported on Windows hosts only.')
root = _find_borealis_root()
if not root:
raise RuntimeError('Unable to locate Borealis.ps1 on this agent.')
ps_exe = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
if not os.path.isfile(ps_exe):
ps_exe = 'powershell.exe'
task_name = f"Borealis Agent - SilentUpdate - {uuid.uuid4().hex}"
task_literal = _ps_literal(task_name)
ps_literal = _ps_literal(ps_exe)
root_literal = _ps_literal(root)
args_literal = _ps_literal('-NoProfile -ExecutionPolicy Bypass -File .\\Borealis.ps1 -SilentUpdate')
cleanup_script = (
"Start-Job -ScriptBlock { Start-Sleep -Seconds 600; "
f"try {{ Unregister-ScheduledTask -TaskName {task_literal} -Confirm:$false -ErrorAction SilentlyContinue }} catch {{}} }} | Out-Null"
)
ps_script = "\n".join(
[
"$ErrorActionPreference='Stop'",
f"$task = {task_literal}",
"try { Unregister-ScheduledTask -TaskName $task -Confirm:$false -ErrorAction SilentlyContinue } catch {}",
f"$action = New-ScheduledTaskAction -Execute {ps_literal} -Argument {args_literal} -WorkingDirectory {root_literal}",
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -DeleteExpiredTaskAfter (New-TimeSpan -Minutes 30)",
"$principal= New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest",
"Register-ScheduledTask -TaskName $task -Action $action -Settings $settings -Principal $principal -Force | Out-Null",
"Start-ScheduledTask -TaskName $task | Out-Null",
cleanup_script,
]
)
flags = 0x08000000 if os.name == 'nt' else 0
proc = subprocess.run(
[ps_exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps_script],
capture_output=True,
text=True,
creationflags=flags,
)
if proc.returncode != 0:
stderr = proc.stderr or proc.stdout or 'scheduled task registration failed'
raise RuntimeError(stderr.strip())
def _canonical_env_key(name: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9_]", "_", (name or "").strip())
return cleaned.upper()
@@ -328,46 +280,6 @@ class Role:
def register_events(self):
sio = self.ctx.sio
@sio.on('agent_silent_update')
async def _on_agent_silent_update(payload):
hostname = None
details = payload if isinstance(payload, dict) else {}
try:
import socket
hostname = socket.gethostname()
target = (details.get('target_hostname') or '').strip().lower()
if target and target != hostname.lower():
return
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _launch_silent_update_task)
try:
await sio.emit(
'agent_silent_update_status',
{
'request_id': details.get('request_id') or '',
'hostname': hostname,
'status': 'started',
},
)
except Exception:
pass
except Exception as e:
try:
await sio.emit(
'agent_silent_update_status',
{
'request_id': details.get('request_id') or '',
'hostname': hostname or '',
'status': 'error',
'error': str(e),
},
)
except Exception:
pass
@sio.on('quick_job_run')
async def _on_quick_job_run(payload):
try:

View File

@@ -18,9 +18,7 @@ import {
MenuItem,
Popover,
TextField,
Tooltip,
Snackbar,
Alert
Tooltip
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import FilterListIcon from "@mui/icons-material/FilterList";
@@ -63,8 +61,6 @@ export default function DeviceList({ onSelectDevice }) {
// Track selection by agent id to avoid duplicate hostname collisions
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [quickJobOpen, setQuickJobOpen] = useState(false);
const [updateLoading, setUpdateLoading] = useState(false);
const [updateStatus, setUpdateStatus] = useState({ open: false, severity: "success", message: "" });
// Saved custom views (from server)
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
@@ -426,49 +422,6 @@ export default function DeviceList({ onSelectDevice }) {
});
};
const triggerSilentUpdate = useCallback(async () => {
const hostnames = Array.from(
new Set(
rows
.filter((r) => selectedIds.has(r.id))
.map((r) => r.hostname)
.filter(Boolean)
)
);
if (!hostnames.length) return;
setUpdateLoading(true);
try {
const resp = await fetch('/api/agents/silent_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostnames }),
});
let payload = null;
try {
payload = await resp.json();
} catch (err) {
payload = null;
}
if (!resp.ok || (payload && payload.error)) {
const msg = payload && payload.error ? payload.error : `HTTP ${resp.status}`;
throw new Error(msg);
}
const count = Array.isArray(payload?.results) ? payload.results.length : hostnames.length;
const requestId = typeof payload?.request_id === 'string' && payload.request_id ? payload.request_id : '';
setUpdateStatus({
open: true,
severity: 'success',
message: `Silent update triggered for ${count} device${count === 1 ? '' : 's'}${requestId ? ` (request ${requestId})` : ''}.`,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setUpdateStatus({ open: true, severity: 'error', message: `Failed to trigger silent update: ${message}` });
} finally {
setUpdateLoading(false);
}
}, [rows, selectedIds]);
// Column drag handlers
const onHeaderDragStart = (colId) => (e) => {
dragColId.current = colId;
@@ -620,22 +573,6 @@ export default function DeviceList({ onSelectDevice }) {
</Box>
{/* Second row: Quick Job button aligned under header title */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
size="small"
disabled={selectedIds.size === 0 || updateLoading}
onClick={triggerSilentUpdate}
sx={{
textTransform: "none",
bgcolor: selectedIds.size === 0 || updateLoading ? "#2d4a6a" : "#1f6feb",
color: "#fff",
'&:hover': {
bgcolor: selectedIds.size === 0 || updateLoading ? "#2d4a6a" : "#388bfd"
}
}}
>
{updateLoading ? "Updating..." : "Update Agent"}
</Button>
<Button
variant="outlined"
size="small"
@@ -1017,23 +954,6 @@ export default function DeviceList({ onSelectDevice }) {
hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)}
/>
)}
<Snackbar
open={updateStatus.open}
autoHideDuration={6000}
onClose={(_event, reason) => {
if (reason === 'clickaway') return;
setUpdateStatus((prev) => ({ ...prev, open: false }));
}}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={() => setUpdateStatus((prev) => ({ ...prev, open: false }))}
severity={updateStatus.severity}
sx={{ width: '100%' }}
>
{updateStatus.message}
</Alert>
</Snackbar>
{assignDialogOpen && (
<Popover
open={assignDialogOpen}

View File

@@ -3126,48 +3126,6 @@ def scripts_quick_run():
return jsonify({"results": results})
@app.route("/api/agents/silent_update", methods=["POST"])
def agent_silent_update():
"""Request connected agents to run the Borealis silent update workflow."""
data = request.get_json(silent=True) or {}
raw_hosts = data.get("hostnames")
if not isinstance(raw_hosts, list):
return jsonify({"error": "hostnames[] must be provided"}), 400
hostnames: List[str] = []
seen: Set[str] = set()
for entry in raw_hosts:
name = str(entry or "").strip()
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
hostnames.append(name)
if not hostnames:
return jsonify({"error": "No valid hostnames provided"}), 400
request_id = uuid.uuid4().hex
results: List[Dict[str, str]] = []
for host in hostnames:
payload = {
"target_hostname": host,
"request_id": request_id,
"requested_at": int(time.time()),
}
socketio.emit("agent_silent_update", payload)
results.append({"hostname": host, "status": "queued"})
_write_service_log(
"server",
f"[silent_update] request_id={request_id} dispatched to {len(hostnames)} host(s): {', '.join(hostnames)}",
)
return jsonify({"results": results, "request_id": request_id})
@app.route("/api/ansible/quick_run", methods=["POST"])
def ansible_quick_run():
"""Queue an Ansible Playbook Quick Job via WebSocket to targeted agents.
@@ -3337,27 +3295,6 @@ def handle_quick_job_result(data):
print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}")
@socketio.on("agent_silent_update_status")
def handle_agent_silent_update_status(data):
try:
hostname = str(data.get("hostname") or "").strip()
status = str(data.get("status") or "").strip() or "unknown"
request_id = str(data.get("request_id") or "").strip()
error = str(data.get("error") or "").strip()
message_parts = [
"[silent_update_status]",
f"host={hostname or 'unknown'}",
f"status={status}",
]
if request_id:
message_parts.append(f"request_id={request_id}")
if error:
message_parts.append(f"error={error}")
_write_service_log("server", " ".join(message_parts))
except Exception:
pass
# ---------------------------------------------
# Ansible Runtime API (Play Recaps)
# ---------------------------------------------