diff --git a/Assemblies/Ansible_Playbooks/Examples/Query_OS_String.json b/Assemblies/Ansible_Playbooks/Examples/Query_OS_String.json new file mode 100644 index 0000000..28be17d --- /dev/null +++ b/Assemblies/Ansible_Playbooks/Examples/Query_OS_String.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "name": "Query OS String [LINUX]", + "description": "Simply returns the operating system in the output.", + "category": "application", + "type": "ansible", + "script": "LS0tCi0gbmFtZTogR2V0IG9wZXJhdGluZyBzeXN0ZW0gaW5mb3JtYXRpb24KICBob3N0czogYWxsCiAgZ2F0aGVyX2ZhY3RzOiB5ZXMKCiAgdGFza3M6CiAgICAtIG5hbWU6IFByaW50IE9TIHN0cmluZwogICAgICBkZWJ1ZzoKICAgICAgICBtc2c6ICJ7eyBhbnNpYmxlX2Rpc3RyaWJ1dGlvbiB9fSB7eyBhbnNpYmxlX2Rpc3RyaWJ1dGlvbl92ZXJzaW9uIH19IHt7IGFuc2libGVfZGlzdHJpYnV0aW9uX3JlbGVhc2UgfCBkZWZhdWx0KCcnKSB9fSIK", + "timeout_seconds": 3600, + "sites": { + "mode": "all", + "values": [] + }, + "variables": [], + "files": [], + "script_encoding": "base64" +} \ No newline at end of file diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index acac841..cfb9348 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -35,7 +35,9 @@ import Login from "./Login.jsx"; import SiteList from "./Sites/Site_List"; import DeviceList from "./Devices/Device_List"; import DeviceDetails from "./Devices/Device_Details"; +import AgentDevices from "./Devices/Agent_Devices.jsx"; import SSHDevices from "./Devices/SSH_Devices.jsx"; +import WinRMDevices from "./Devices/WinRM_Devices.jsx"; import AssemblyList from "./Assemblies/Assembly_List"; import AssemblyEditor from "./Assemblies/Assembly_Editor"; import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List"; @@ -160,8 +162,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; items.push({ label: "Site List", page: "sites" }); break; case "devices": + items.push({ label: "Inventory", page: "devices" }); items.push({ label: "Devices", page: "devices" }); - items.push({ label: "Device List", page: "devices" }); break; case "device_details": items.push({ label: "Devices", page: "devices" }); @@ -203,14 +205,21 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; items.push({ label: "Automation", page: "jobs" }); items.push({ label: "Community Content", page: "community" }); break; - case "devices": + case "agent_devices": items.push({ label: "Inventory", page: "devices" }); items.push({ label: "Devices", page: "devices" }); + items.push({ label: "Agent Devices", page: "agent_devices" }); break; case "ssh_devices": items.push({ label: "Inventory", page: "devices" }); + items.push({ label: "Devices", page: "devices" }); items.push({ label: "SSH Devices", page: "ssh_devices" }); break; + case "winrm_devices": + items.push({ label: "Inventory", page: "devices" }); + items.push({ label: "Devices", page: "devices" }); + items.push({ label: "WinRM Devices", page: "winrm_devices" }); + break; case "access_credentials": items.push({ label: "Access Management", page: "access_credentials" }); items.push({ label: "Credentials", page: "access_credentials" }); @@ -565,7 +574,13 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; const isAdmin = (String(userRole || '').toLowerCase() === 'admin'); useEffect(() => { - if (!isAdmin && (currentPage === 'server_info' || currentPage === 'access_credentials' || currentPage === 'access_users' || currentPage === 'ssh_devices')) { + const requiresAdmin = currentPage === 'server_info' + || currentPage === 'access_credentials' + || currentPage === 'access_users' + || currentPage === 'ssh_devices' + || currentPage === 'winrm_devices' + || currentPage === 'agent_devices'; + if (!isAdmin && requiresAdmin) { setNotAuthorizedOpen(true); setCurrentPage('devices'); } @@ -593,8 +608,19 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; }} /> ); + case "agent_devices": + return ( + { + setSelectedDevice(d); + setCurrentPage("device_details"); + }} + /> + ); case "ssh_devices": return ; + case "winrm_devices": + return ; case "device_details": return ( diff --git a/Data/Server/WebUI/src/Devices/Add_Device.jsx b/Data/Server/WebUI/src/Devices/Add_Device.jsx new file mode 100644 index 0000000..f44945d --- /dev/null +++ b/Data/Server/WebUI/src/Devices/Add_Device.jsx @@ -0,0 +1,219 @@ +import React, { useEffect, useState } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + MenuItem, + Typography +} from "@mui/material"; + +const TYPE_OPTIONS = [ + { value: "ssh", label: "SSH" }, + { value: "winrm", label: "WinRM" } +]; + +const initialForm = { + hostname: "", + address: "", + description: "", + operating_system: "" +}; + +export default function AddDevice({ + open, + onClose, + defaultType = null, + onCreated +}) { + const [type, setType] = useState(defaultType || "ssh"); + const [form, setForm] = useState(initialForm); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (open) { + setType(defaultType || "ssh"); + setForm(initialForm); + setError(""); + } + }, [open, defaultType]); + + const handleClose = () => { + if (submitting) return; + onClose && onClose(); + }; + + const handleChange = (field) => (event) => { + const value = event.target.value; + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async () => { + if (submitting) return; + const trimmedHostname = form.hostname.trim(); + const trimmedAddress = form.address.trim(); + if (!trimmedHostname) { + setError("Hostname is required."); + return; + } + if (!type) { + setError("Select a device type."); + return; + } + if (!trimmedAddress) { + setError("Address is required."); + return; + } + setSubmitting(true); + setError(""); + const payload = { + hostname: trimmedHostname, + address: trimmedAddress, + description: form.description.trim(), + operating_system: form.operating_system.trim() + }; + const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices"; + try { + const resp = await fetch(apiBase, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); + onCreated && onCreated(data.device || null); + onClose && onClose(); + } catch (err) { + setError(String(err.message || err)); + } finally { + setSubmitting(false); + } + }; + + const dialogTitle = defaultType + ? `Add ${defaultType.toUpperCase()} Device` + : "Add Device"; + + const typeLabel = (TYPE_OPTIONS.find((opt) => opt.value === type) || TYPE_OPTIONS[0]).label; + + return ( + + {dialogTitle} + + {!defaultType && ( + setType(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#1f1f1f", + color: "#fff", + "& fieldset": { borderColor: "#555" }, + "&:hover fieldset": { borderColor: "#888" } + }, + "& .MuiInputLabel-root": { color: "#aaa" } + }} + > + {TYPE_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + )} + + + + + {error && ( + + {error} + + )} + + + + + + + ); +} diff --git a/Data/Server/WebUI/src/Devices/Agent_Devices.jsx b/Data/Server/WebUI/src/Devices/Agent_Devices.jsx new file mode 100644 index 0000000..9f0f112 --- /dev/null +++ b/Data/Server/WebUI/src/Devices/Agent_Devices.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import DeviceList from "./Device_List.jsx"; + +export default function AgentDevices(props) { + return ( + + ); +} diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 1d0efb3..c78512c 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -27,6 +27,7 @@ import AddIcon from "@mui/icons-material/Add"; import CachedIcon from "@mui/icons-material/Cached"; import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx"; import QuickJob from "../Scheduling/Quick_Job.jsx"; +import AddDevice from "./Add_Device.jsx"; function formatLastSeen(tsSec, offlineAfter = 300) { if (!tsSec) return "unknown"; @@ -66,7 +67,14 @@ function formatUptime(seconds) { return parts.join(' '); } -export default function DeviceList({ onSelectDevice }) { +export default function DeviceList({ + onSelectDevice, + filterMode = "all", + title, + showAddButton, + addButtonLabel, + defaultAddType, +}) { const [rows, setRows] = useState([]); const [orderBy, setOrderBy] = useState("status"); const [order, setOrder] = useState("desc"); @@ -76,6 +84,36 @@ 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 [addDeviceOpen, setAddDeviceOpen] = useState(false); + const [addDeviceType, setAddDeviceType] = useState(null); + const computedTitle = useMemo(() => { + if (title) return title; + switch (filterMode) { + case "agent": + return "Agent Devices"; + case "ssh": + return "SSH Devices"; + case "winrm": + return "WinRM Devices"; + default: + return "Device Inventory"; + } + }, [filterMode, title]); + const derivedDefaultType = useMemo(() => { + if (defaultAddType !== undefined) return defaultAddType; + if (filterMode === "ssh" || filterMode === "winrm") return filterMode; + return null; + }, [defaultAddType, filterMode]); + const derivedAddLabel = useMemo(() => { + if (addButtonLabel) return addButtonLabel; + if (filterMode === "ssh") return "Add SSH Device"; + if (filterMode === "winrm") return "Add WinRM Device"; + return "Add Device"; + }, [addButtonLabel, filterMode]); + const derivedShowAddButton = useMemo(() => { + if (typeof showAddButton === "boolean") return showAddButton; + return filterMode !== "agent"; + }, [showAddButton, filterMode]); // Saved custom views (from server) const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}] @@ -305,6 +343,7 @@ export default function DeviceList({ onSelectDevice }) { 0 ) || 0; const connectionType = (device.connection_type || summary.connection_type || '').trim().toLowerCase(); + const connectionLabel = connectionType === 'ssh' ? 'SSH' : connectionType === 'winrm' ? 'WinRM' : ''; const connectionEndpoint = (device.connection_endpoint || summary.connection_endpoint || '').trim(); const memoryList = Array.isArray(device.memory) ? device.memory : []; @@ -329,7 +368,7 @@ export default function DeviceList({ onSelectDevice }) { lastSeenDisplay: formatLastSeen(lastSeen), os: osName, lastUser, - type, + type: type || connectionLabel || '', site: device.site_name || 'Not Configured', siteId: device.site_id || null, siteDescription: device.site_description || '', @@ -360,17 +399,27 @@ export default function DeviceList({ onSelectDevice }) { summary, details: device.details || {}, connectionType, + connectionLabel, connectionEndpoint, - isRemote: connectionType === 'ssh', + isRemote: Boolean(connectionLabel), }; }); - setRows(normalized); + let filtered = normalized; + if (filterMode === "agent") { + filtered = normalized.filter((row) => !row.connectionType); + } else if (filterMode === "ssh") { + filtered = normalized.filter((row) => row.connectionType === "ssh"); + } else if (filterMode === "winrm") { + filtered = normalized.filter((row) => row.connectionType === "winrm"); + } + + setRows(filtered); } catch (e) { console.warn('Failed to load devices:', e); setRows([]); } - }, [repoHash, fetchLatestRepoHash, computeAgentVersion]); + }, [repoHash, fetchLatestRepoHash, computeAgentVersion, filterMode]); const fetchViews = useCallback(async () => { try { @@ -653,7 +702,7 @@ export default function DeviceList({ onSelectDevice }) { - Device Inventory + {computedTitle} {/* Views dropdown + add button */} @@ -753,6 +802,20 @@ export default function DeviceList({ onSelectDevice }) { + {derivedShowAddButton && ( + + )} {/* Second row: Quick Job button aligned under header title */} @@ -1224,6 +1287,19 @@ export default function DeviceList({ onSelectDevice }) { )} + { + setAddDeviceOpen(false); + setAddDeviceType(derivedDefaultType ?? null); + }} + onCreated={() => { + setAddDeviceOpen(false); + setAddDeviceType(derivedDefaultType ?? null); + fetchDevices({ refreshRepo: true }); + }} + /> ); } diff --git a/Data/Server/WebUI/src/Devices/SSH_Devices.jsx b/Data/Server/WebUI/src/Devices/SSH_Devices.jsx index 2d61743..e993985 100644 --- a/Data/Server/WebUI/src/Devices/SSH_Devices.jsx +++ b/Data/Server/WebUI/src/Devices/SSH_Devices.jsx @@ -23,6 +23,7 @@ import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import RefreshIcon from "@mui/icons-material/Refresh"; import { ConfirmDeleteDialog } from "../Dialogs.jsx"; +import AddDevice from "./Add_Device.jsx"; const tableStyles = { "& th, & td": { @@ -45,7 +46,19 @@ const defaultForm = { operating_system: "" }; -export default function SSHDevices() { +export default function SSHDevices({ type = "ssh" }) { + const typeLabel = type === "winrm" ? "WinRM" : "SSH"; + const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices"; + const pageTitle = `${typeLabel} Devices`; + const addButtonLabel = `Add ${typeLabel} Device`; + const addressLabel = `${typeLabel} Address`; + const loadingLabel = `Loading ${typeLabel} devices…`; + const emptyLabel = `No ${typeLabel} devices have been added yet.`; + const descriptionText = type === "winrm" + ? "Manage remote endpoints reachable via WinRM for playbook execution." + : "Manage remote endpoints reachable via SSH for playbook execution."; + const editDialogTitle = `Edit ${typeLabel} Device`; + const newDialogTitle = `New ${typeLabel} Device`; const [rows, setRows] = useState([]); const [orderBy, setOrderBy] = useState("hostname"); const [order, setOrder] = useState("asc"); @@ -58,6 +71,7 @@ export default function SSHDevices() { const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); + const [addDeviceOpen, setAddDeviceOpen] = useState(false); const isEdit = Boolean(editTarget); @@ -65,7 +79,7 @@ export default function SSHDevices() { setLoading(true); setError(""); try { - const resp = await fetch("/api/ssh_devices"); + const resp = await fetch(apiBase); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data?.error || `HTTP ${resp.status}`); @@ -79,7 +93,7 @@ export default function SSHDevices() { } finally { setLoading(false); } - }, []); + }, [apiBase]); useEffect(() => { loadDevices(); @@ -119,9 +133,7 @@ export default function SSHDevices() { }; const openCreate = () => { - setEditTarget(null); - setForm(defaultForm); - setDialogOpen(true); + setAddDeviceOpen(true); setFormError(""); }; @@ -164,16 +176,14 @@ export default function SSHDevices() { setSubmitting(true); setFormError(""); try { - const resp = await fetch( - isEdit - ? `/api/ssh_devices/${encodeURIComponent(editTarget.hostname)}` - : "/api/ssh_devices", - { - method: isEdit ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - } - ); + const endpoint = isEdit + ? `${apiBase}/${encodeURIComponent(editTarget.hostname)}` + : apiBase; + const resp = await fetch(endpoint, { + method: isEdit ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { throw new Error(data?.error || `HTTP ${resp.status}`); @@ -205,7 +215,7 @@ export default function SSHDevices() { if (!deleteTarget) return; setDeleteBusy(true); try { - const resp = await fetch(`/api/ssh_devices/${encodeURIComponent(deleteTarget.hostname)}`, { + const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, { method: "DELETE" }); const data = await resp.json().catch(() => ({})); @@ -232,10 +242,10 @@ export default function SSHDevices() { > - SSH Devices + {pageTitle} - Manage remote endpoints reachable via SSH for playbook execution. + {descriptionText} @@ -256,7 +266,7 @@ export default function SSHDevices() { sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }} onClick={openCreate} > - New SSH Device + {addButtonLabel} @@ -269,7 +279,7 @@ export default function SSHDevices() { {loading && ( - Loading SSH devices… + {loadingLabel} )} @@ -291,7 +301,7 @@ export default function SSHDevices() { direction={orderBy === "address" ? order : "asc"} onClick={handleSort("address")} > - SSH Address + {addressLabel} @@ -341,7 +351,7 @@ export default function SSHDevices() { {!sortedRows.length && !loading && ( - No SSH devices have been added yet. + {emptyLabel} )} @@ -355,7 +365,7 @@ export default function SSHDevices() { maxWidth="sm" PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }} > - {isEdit ? "Edit SSH Device" : "New SSH Device"} + {isEdit ? editDialogTitle : newDialogTitle} setForm((prev) => ({ ...prev, address: e.target.value }))} fullWidth @@ -390,7 +400,7 @@ export default function SSHDevices() { }, "& .MuiInputLabel-root": { color: "#aaa" } }} - helperText="IP or FQDN Borealis can reach over SSH." + helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`} /> setDeleteTarget(null)} onConfirm={handleDelete} confirmDisabled={deleteBusy} /> + setAddDeviceOpen(false)} + onCreated={() => { + setAddDeviceOpen(false); + loadDevices(); + }} + /> ); } diff --git a/Data/Server/WebUI/src/Devices/WinRM_Devices.jsx b/Data/Server/WebUI/src/Devices/WinRM_Devices.jsx new file mode 100644 index 0000000..eb4a161 --- /dev/null +++ b/Data/Server/WebUI/src/Devices/WinRM_Devices.jsx @@ -0,0 +1,6 @@ +import React from "react"; +import SSHDevices from "./SSH_Devices.jsx"; + +export default function WinRMDevices(props) { + return ; +} diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index eda7574..053055b 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -154,7 +154,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) { })()} {/* Inventory */} {(() => { - const groupActive = currentPage === "devices" || currentPage === "ssh_devices"; + const groupActive = ["devices", "ssh_devices", "winrm_devices", "agent_devices"].includes(currentPage); return ( } label="Devices" pageKey="devices" /> + } label="Agent Devices" pageKey="agent_devices" indent /> } label="SSH Devices" pageKey="ssh_devices" indent /> + } label="WinRM Devices" pageKey="winrm_devices" indent /> ); diff --git a/Data/Server/server.py b/Data/Server/server.py index 8c5a1ab..8b6d2ad 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -19,8 +19,10 @@ Section Guide: """ import eventlet -# Monkey-patch stdlib for cooperative sockets -eventlet.monkey_patch() +# Monkey-patch stdlib for cooperative sockets (keep real threads for tpool usage) +eventlet.monkey_patch(thread=False) + +from eventlet import tpool import requests import re @@ -43,6 +45,7 @@ import subprocess import stat import traceback from threading import Lock + from datetime import datetime, timezone try: @@ -2045,7 +2048,7 @@ def _queue_server_ansible_run( "started_ts": _now_ts(), } try: - socketio.start_background_task(_execute_server_ansible_run, ctx) + socketio.start_background_task(_execute_server_ansible_run, ctx, None) except Exception as exc: _ansible_log_server(f"[server_run] failed to queue background task run_id={run_id}: {exc}") _execute_server_ansible_run(ctx, immediate_error=str(exc)) @@ -2147,7 +2150,8 @@ def _execute_server_ansible_run(ctx: Dict[str, Any], immediate_error: Optional[s f"[server_run] start run_id={run_id} host='{hostname}' playbook='{playbook_rel_path}' credential={credential_id}" ) - proc = subprocess.run( + proc = tpool.execute( + subprocess.run, cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -4967,6 +4971,7 @@ def _fetch_devices( *, connection_type: Optional[str] = None, hostname: Optional[str] = None, + only_agents: bool = False, ) -> List[Dict[str, Any]]: try: conn = _db_conn() @@ -4986,6 +4991,8 @@ def _fetch_devices( if hostname: clauses.append("LOWER(d.hostname) = LOWER(?)") params.append(hostname.lower()) + if only_agents: + clauses.append("(d.connection_type IS NULL OR TRIM(d.connection_type) = '')") if clauses: sql += " WHERE " + " AND ".join(clauses) cur.execute(sql, params) @@ -5060,21 +5067,105 @@ def list_devices(): return jsonify({"devices": devices}) -@app.route("/api/ssh_devices", methods=["GET", "POST"]) -def api_ssh_devices(): +def _upsert_remote_device( + connection_type: str, + hostname: str, + address: Optional[str], + description: Optional[str], + os_hint: Optional[str], + *, + ensure_existing_type: Optional[str], +) -> Dict[str, Any]: + conn = _db_conn() + cur = conn.cursor() + existing = _load_device_snapshot(cur, hostname=hostname) + existing_type = (existing or {}).get("summary", {}).get("connection_type") or "" + existing_type = existing_type.strip().lower() + + if ensure_existing_type and existing_type != ensure_existing_type.lower(): + conn.close() + raise ValueError("device not found") + if ensure_existing_type is None and existing_type and existing_type != connection_type.lower(): + conn.close() + raise ValueError("device already exists with different connection type") + + created_ts = existing.get("summary", {}).get("created_at") if existing else None + if not created_ts: + created_ts = _now_ts() + + endpoint = address or (existing.get("summary", {}).get("connection_endpoint") if existing else "") + if not endpoint: + conn.close() + raise ValueError("address is required") + + description_val = description + if description_val is None: + description_val = existing.get("summary", {}).get("description") if existing else "" + + os_value = os_hint or (existing.get("summary", {}).get("operating_system") if existing else "") + os_value = os_value or "" + + device_type_label = "SSH Remote" if connection_type.lower() == "ssh" else "WinRM Remote" + + summary_payload = { + "connection_type": connection_type.lower(), + "connection_endpoint": endpoint, + "internal_ip": endpoint, + "external_ip": endpoint, + "device_type": device_type_label, + "operating_system": os_value, + "last_seen": 0, + } + + _device_upsert( + cur, + hostname, + description_val, + {"summary": summary_payload}, + created_ts, + ) + conn.commit() + conn.close() + + devices = _fetch_devices(hostname=hostname) + if not devices: + raise RuntimeError("failed to load device after upsert") + return devices[0] + + +def _delete_remote_device(connection_type: str, hostname: str) -> None: + conn = _db_conn() + cur = conn.cursor() + existing = _load_device_snapshot(cur, hostname=hostname) + existing_type = (existing or {}).get("summary", {}).get("connection_type") or "" + if (existing_type or "").strip().lower() != connection_type.lower(): + conn.close() + raise ValueError("device not found") + cur.execute("DELETE FROM device_sites WHERE device_hostname = ?", (hostname,)) + cur.execute(f"DELETE FROM {DEVICE_TABLE} WHERE hostname = ?", (hostname,)) + conn.commit() + conn.close() + + +def _remote_devices_collection(connection_type: str): chk = _require_admin() if chk: return chk if request.method == "GET": try: - devices = _fetch_devices(connection_type="ssh") + devices = _fetch_devices(connection_type=connection_type) return jsonify({"devices": devices}) except RuntimeError as exc: return jsonify({"error": str(exc)}), 500 data = request.get_json(silent=True) or {} hostname = _clean_device_str(data.get("hostname")) or "" - address = _clean_device_str(data.get("address") or data.get("connection_endpoint") or data.get("endpoint")) or "" + address = _clean_device_str( + data.get("address") + or data.get("connection_endpoint") + or data.get("endpoint") + or data.get("host") + ) or "" description = _clean_device_str(data.get("description")) or "" os_hint = _clean_device_str(data.get("operating_system") or data.get("os")) or "" if not hostname: @@ -5082,50 +5173,23 @@ def api_ssh_devices(): if not address: return jsonify({"error": "address is required"}), 400 - now_ts = _now_ts() - conn = None try: - conn = _db_conn() - cur = conn.cursor() - existing = _load_device_snapshot(cur, hostname=hostname) - if existing and (existing.get("summary", {}).get("connection_type") or "").lower() not in ("", "ssh"): - conn.close() - return jsonify({"error": "Device already exists and is managed by an agent"}), 409 - - summary_payload = { - "connection_type": "ssh", - "connection_endpoint": address, - "internal_ip": address, - "external_ip": address, - "device_type": "SSH Remote", - "operating_system": os_hint or (existing.get("summary", {}).get("operating_system") if existing else ""), - "last_seen": 0, - } - - _device_upsert( - cur, + device = _upsert_remote_device( + connection_type, hostname, + address, description, - {"summary": summary_payload}, - now_ts, + os_hint, + ensure_existing_type=None, ) - conn.commit() - conn.close() + except ValueError as exc: + return jsonify({"error": str(exc)}), 409 except Exception as exc: - if conn: - conn.close() return jsonify({"error": str(exc)}), 500 - - try: - devices = _fetch_devices(hostname=hostname) - except RuntimeError as exc: - return jsonify({"error": str(exc)}), 500 - device = devices[0] if devices else None return jsonify({"device": device}), 201 -@app.route("/api/ssh_devices/", methods=["PUT", "DELETE"]) -def api_ssh_device_detail(hostname: str): +def _remote_device_detail(connection_type: str, hostname: str): chk = _require_admin() if chk: return chk @@ -5133,71 +5197,71 @@ def api_ssh_device_detail(hostname: str): if not normalized_host: return jsonify({"error": "invalid hostname"}), 400 - conn = None + if request.method == "DELETE": + try: + _delete_remote_device(connection_type, normalized_host) + except ValueError as exc: + return jsonify({"error": str(exc)}), 404 + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + return jsonify({"status": "ok"}) + + data = request.get_json(silent=True) or {} + address = _clean_device_str( + data.get("address") + or data.get("connection_endpoint") + or data.get("endpoint") + ) + description = data.get("description") + os_hint = _clean_device_str(data.get("operating_system") or data.get("os")) + if address is None and description is None and os_hint is None: + return jsonify({"error": "no fields to update"}), 400 try: - conn = _db_conn() - cur = conn.cursor() - existing = _load_device_snapshot(cur, hostname=normalized_host) - if not existing: - conn.close() - return jsonify({"error": "device not found"}), 404 - existing_type = (existing.get("summary", {}).get("connection_type") or "").lower() - if existing_type != "ssh": - conn.close() - return jsonify({"error": "device is not managed as SSH"}), 400 - - if request.method == "DELETE": - cur.execute("DELETE FROM device_sites WHERE device_hostname = ?", (normalized_host,)) - cur.execute(f"DELETE FROM {DEVICE_TABLE} WHERE hostname = ?", (normalized_host,)) - conn.commit() - conn.close() - return jsonify({"status": "ok"}) - - data = request.get_json(silent=True) or {} - new_address = _clean_device_str(data.get("address") or data.get("connection_endpoint") or data.get("endpoint")) - new_description = data.get("description") - new_os = _clean_device_str(data.get("operating_system") or data.get("os")) - - summary = existing.get("summary", {}) - description_value = summary.get("description") or existing.get("description") or "" - if new_description is not None: - try: - description_value = str(new_description).strip() - except Exception: - description_value = summary.get("description") or "" - - endpoint_value = new_address or summary.get("connection_endpoint") or "" - os_value = new_os or summary.get("operating_system") or "" - summary_payload = { - "connection_type": "ssh", - "connection_endpoint": endpoint_value, - "internal_ip": endpoint_value or summary.get("internal_ip") or "", - "external_ip": endpoint_value or summary.get("external_ip") or "", - "device_type": summary.get("device_type") or "SSH Remote", - "operating_system": os_value, - "last_seen": 0, - } - created_ts = summary.get("created_at") or existing.get("created_at") or _now_ts() - _device_upsert( - cur, + device = _upsert_remote_device( + connection_type, normalized_host, - description_value, - {"summary": summary_payload}, - created_ts, + address if address is not None else "", + description, + os_hint, + ensure_existing_type=connection_type, ) - conn.commit() - conn.close() + except ValueError as exc: + return jsonify({"error": str(exc)}), 404 except Exception as exc: - if conn: - conn.close() return jsonify({"error": str(exc)}), 500 + return jsonify({"device": device}) + +@app.route("/api/ssh_devices", methods=["GET", "POST"]) +def api_ssh_devices(): + return _remote_devices_collection("ssh") + + +@app.route("/api/ssh_devices/", methods=["PUT", "DELETE"]) +def api_ssh_device_detail(hostname: str): + return _remote_device_detail("ssh", hostname) + + +@app.route("/api/winrm_devices", methods=["GET", "POST"]) +def api_winrm_devices(): + return _remote_devices_collection("winrm") + + +@app.route("/api/winrm_devices/", methods=["PUT", "DELETE"]) +def api_winrm_device_detail(hostname: str): + return _remote_device_detail("winrm", hostname) + + +@app.route("/api/agent_devices", methods=["GET"]) +def api_agent_devices(): + chk = _require_admin() + if chk: + return chk try: - devices = _fetch_devices(hostname=normalized_host) + devices = _fetch_devices(only_agents=True) + return jsonify({"devices": devices}) except RuntimeError as exc: return jsonify({"error": str(exc)}), 500 - device = devices[0] if devices else None - return jsonify({"device": device}) # Endpoint: /api/devices/ — methods GET. @@ -5483,6 +5547,25 @@ def _persist_last_seen(hostname: str, last_seen: int, agent_id: str = None): print(f"[WARN] Failed to persist last_seen for {hostname}: {e}") +def _normalize_guid(value: Optional[str]) -> str: + candidate = (value or "").strip() + if not candidate: + return "" + candidate = candidate.replace("{", "").replace("}", "") + try: + upper = candidate.upper() + if upper.count("-") == 4 and len(upper) == 36: + return upper + if len(candidate) == 32 and all(c in "0123456789abcdefABCDEF" for c in candidate): + grouped = "-".join( + [candidate[0:8], candidate[8:12], candidate[12:16], candidate[16:20], candidate[20:32]] + ) + return grouped.upper() + except Exception: + pass + return candidate.upper() + + def load_agents_from_db(): """Populate registered_agents with any devices stored in the database.""" try: @@ -5587,16 +5670,6 @@ def _device_rows_for_agent(cur, agent_id: str) -> List[Dict[str, Any]]: return results -def _normalize_guid(value: Optional[str]) -> str: - candidate = (value or "").strip() - if not candidate: - return "" - try: - return str(uuid.UUID(candidate)) - except Exception: - return candidate - - def _ensure_agent_guid_for_hostname(cur, hostname: str, agent_id: Optional[str] = None) -> Optional[str]: normalized_host = (hostname or "").strip() if not normalized_host: