Add Server Buddy desktop app with local Linux assessment and reporting

This commit is contained in:
2026-10-30 09:46:01 -07:00
commit 365f97942a
29 changed files with 1789 additions and 0 deletions

1
server_buddy/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,44 @@
def assess(data):
inventory = data["inventory"]
findings = []
def add(category, classification, evidence, severity, points):
findings.append(dict(category=category, classification=classification, evidence=evidence,
severity=severity, points=points))
for row in inventory.get("roles", []):
add("Installed role", "Potential dependency", row["name"], "medium", 10)
linux_apps = ('nginx', 'apache2', 'httpd', 'mysql', 'mysqld', 'mariadb', 'postgresql',
'redis', 'docker', 'containerd', 'smbd', 'smb', 'nfs-server', 'named', 'haproxy', 'tomcat')
unclassified = []
for row in inventory.get("services", []):
if row.get("state") == "Running":
if data['server'].get('platform') == 'linux' and not any(
row['name'].split('@')[0].split('.')[0] == app or row['name'].startswith(app + '-')
for app in linux_apps
):
unclassified.append(row['name'])
continue
add("Active service", "Potential dependency", f"{row['name']} running as {row.get('account', 'unknown')}", "medium", 10)
if unclassified:
add("Unclassified services", "Unknown or insufficient evidence",
f"{len(unclassified)} running Linux services lack an application rule. Administrator review required; examples: " + ', '.join(unclassified[:8]), "low", 5)
for row in inventory.get("network", []):
if row.get("state") == "Established":
add("Network activity", "Confirmed observed activity", f"{row['local_address']}:{row['local_port']} ↔ {row['remote_address']}:{row['remote_port']}; direction unverified", "high", 20)
elif row.get("state") == "Listen":
add("Listening port", "Potential dependency", f"TCP {row['local_port']} ({row.get('process', 'unknown')})", "medium", 5)
for row in inventory.get("tasks", []):
if row.get("enabled"):
add("Scheduled workload", "Potential dependency", f"{row['name']}; account {row.get('account', 'unknown')}", "medium", 10)
for row in inventory.get("storage", []):
if row.get("type") in ("SMB Share", "NFS Export", "Samba Share"):
add("Shared storage", "Potential dependency", f"{row['name']}: {row['path']}", "high", 15)
for row in inventory.get("storage", []):
if row.get("type") == "Remote Mount":
add("Remote storage consumer", "Potential dependency", f"{row['name']} mounted at {row['path']}; access and use unverified", "medium", 10)
for name, check in data["checks"].items():
if check["status"] != "complete":
add("Missing evidence", "Unknown or insufficient evidence", f"{name}: {check['message']}", "medium", 10)
add("Observation limits", "Unknown or insufficient evidence", "Point-in-time inventory cannot establish infrequent, offline, historical or external dependencies. No active connections does not prove a server is unused.", "low", 5)
score = min(100, sum(f["points"] for f in findings))
level = "critical" if score >= 90 else "high" if score >= 60 else "medium" if score >= 30 else "low" if score else "informational"
return {"score": score, "level": level, "findings": findings}

View File

@@ -0,0 +1,140 @@
{
"schema_version": "1.0",
"synthetic": true,
"collected_at": "2026-10-01T12:00:00Z",
"server": {
"hostname": "demo-web-01.example.test",
"operating_system": "Windows Server 2022 Standard",
"os_build": "20348",
"last_boot": "2026-09-25T06:30:00Z",
"ip_addresses": [
"192.0.2.20"
],
"network_adapters": [
"Synthetic Ethernet adapter"
],
"domain": "example.test",
"platform": "windows"
},
"checks": {
"roles": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"services": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"network": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"tasks": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"software": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"storage": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"certificates": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"ad": {
"status": "not_collected",
"message": "Optional Active Directory enrichment is not implemented."
}
},
"inventory": {
"roles": [
{
"name": "Web-Server",
"state": "Installed"
},
{
"name": "File-Services",
"state": "Installed"
}
],
"services": [
{
"name": "W3SVC",
"display_name": "World Wide Web Publishing Service",
"state": "Running",
"startup": "Automatic",
"account": "LocalSystem",
"executable": "C:\\Windows\\System32\\svchost.exe"
}
],
"network": [
{
"state": "Listen",
"local_address": "0.0.0.0",
"local_port": 443,
"remote_address": "",
"remote_port": 0,
"pid": 4,
"process": "System",
"direction": "Unknown"
},
{
"state": "Established",
"local_address": "192.0.2.20",
"local_port": 443,
"remote_address": "192.0.2.45",
"remote_port": 52104,
"pid": 4,
"process": "System",
"direction": "Unknown"
}
],
"tasks": [
{
"name": "Nightly export",
"path": "\\Operations\\",
"enabled": true,
"account": "EXAMPLE\\svc_export",
"executable": "C:\\Operations\\export.exe",
"arguments": "[omitted: potentially sensitive]",
"triggers": "Daily 02:00"
}
],
"software": [
{
"name": "Example Reporting Service",
"publisher": "Example Software",
"version": "4.2",
"installation_date": "2024-06-12"
}
],
"storage": [
{
"type": "Volume",
"name": "C:",
"path": "C:\\",
"capacity_gib": 160,
"free_gib": 72
},
{
"type": "SMB Share",
"name": "Reports",
"path": "D:\\Reports",
"capacity_gib": null,
"free_gib": null
}
],
"certificates": [
{
"subject": "CN=demo-web-01.example.test",
"issuer": "CN=Example Test CA",
"expires": "2027-06-01T00:00:00Z",
"thumbprint": "0123456789ABCDEF0123456789ABCDEF01234567"
}
]
}
}

View File

@@ -0,0 +1,129 @@
{
"schema_version": "1.0",
"synthetic": true,
"collected_at": "2026-10-01T12:00:00Z",
"server": {
"hostname": "demo-rhel-01.example.test",
"platform": "linux",
"operating_system": "Red Hat Enterprise Linux 9.4 (synthetic)",
"os_build": "5.14.0 (synthetic)",
"last_boot": "2026-09-25T06:30:00Z",
"ip_addresses": [
"192.0.2.30"
],
"network_adapters": [
"ens192 (synthetic)"
],
"domain": "Unknown; directory integration not collected"
},
"checks": {
"roles": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"services": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"network": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"tasks": {
"status": "partial",
"message": "Synthetic system timer only; user timers, crontabs and application schedulers require verification."
},
"software": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"storage": {
"status": "complete",
"message": "Synthetic fixture; no remote collection performed."
},
"certificates": {
"status": "not_collected",
"message": "Linux certificate locations are application-specific; no universal machine certificate store."
},
"ad": {
"status": "not_collected",
"message": "Directory integration and external identity dependencies not collected."
}
},
"inventory": {
"roles": [
{
"name": "Web server (inferred from installed nginx package)",
"state": "Potential workload; verify configuration"
}
],
"services": [
{
"name": "nginx.service",
"display_name": "Nginx web server",
"state": "Running",
"startup": "enabled",
"account": "nginx",
"executable": "/usr/sbin/nginx"
}
],
"network": [
{
"state": "Listen",
"local_address": "0.0.0.0",
"local_port": 443,
"remote_address": "",
"remote_port": 0,
"pid": 1234,
"process": "nginx",
"direction": "Unknown"
},
{
"state": "Established",
"local_address": "192.0.2.30",
"local_port": 443,
"remote_address": "192.0.2.45",
"remote_port": 52104,
"pid": 1234,
"process": "nginx",
"direction": "Unknown"
}
],
"tasks": [
{
"name": "reports.timer",
"path": "systemd system timer",
"enabled": true,
"account": "Unknown; service unit not inspected",
"executable": "Unknown",
"arguments": "[not collected]",
"triggers": "Daily 02:00 (synthetic)"
}
],
"software": [
{
"name": "nginx",
"publisher": "Red Hat (synthetic)",
"version": "1.20.1 (synthetic)",
"installation_date": "Unknown"
}
],
"storage": [
{
"type": "Volume",
"name": "/dev/mapper/rhel-root",
"path": "/",
"capacity_gib": 80,
"free_gib": 32
},
{
"type": "NFS Export",
"name": "reports",
"path": "/srv/reports",
"capacity_gib": null,
"free_gib": null
}
],
"certificates": []
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,13 @@
import json
from pathlib import Path
from server_buddy.core.validation import validate_hostname
DEMO_HOST = "demo-web-01.example.test"
LINUX_DEMO_HOST = "demo-rhel-01.example.test"
DEMO_FILES = {DEMO_HOST: "demo_inventory.json", LINUX_DEMO_HOST: "demo_linux_inventory.json"}
def collect_demo(hostname):
hostname = validate_hostname(hostname)
if hostname not in DEMO_FILES:
raise ValueError("Demo mode supports " + " or ".join(DEMO_FILES) + ". No real host was contacted.")
return json.loads((Path(__file__).resolve().parents[1] / "assets" / DEMO_FILES[hostname]).read_text())

View File

@@ -0,0 +1,47 @@
"""Offline intake shared by Windows and Linux desktops. No code is executed."""
import json
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
MAX_IMPORT_BYTES = 10 * 1024 * 1024
SCHEMA = Path(__file__).resolve().parents[2] / 'schemas' / 'inventory-v1.schema.json'
# Inventory fields allowed through the report boundary. Command arguments, environment
# variables and private key material are deliberately excluded.
ALLOWED = {
'roles': {'name', 'state'},
'services': {'name', 'display_name', 'state', 'startup', 'account'},
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
'tasks': {'name', 'path', 'enabled', 'account', 'triggers'},
'software': {'name', 'publisher', 'version', 'installation_date'},
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib'},
'certificates': {'subject', 'issuer', 'expires', 'thumbprint'},
}
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_build', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}
def load_inventory(path):
with Path(path).open('rb') as source:
raw = source.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
raise ValueError('Inventory exceeds the 10 MiB import limit.')
try:
data = json.loads(raw.decode('utf-8-sig'))
except (ValueError, UnicodeError):
raise ValueError('The selected file is not valid UTF-8 inventory JSON.') from None
# A report wrapper is accepted, but its precomputed assessment is never trusted.
if isinstance(data, dict) and 'inventory_result' in data:
data = data['inventory_result']
schema = json.loads(SCHEMA.read_text(encoding='utf-8'))
errors = list(Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors(data))
if errors:
raise ValueError('Inventory does not match the v1 schema; required fields, types or check statuses are invalid.')
# Keep diagnostic contents out of logs and exported reports. Diagnostics may
# contain command lines or credentials from external collection tools.
data['checks'] = {name: {'status': check['status'], 'message':
'Imported check status. Consult collector diagnostics separately; details excluded to limit secret exposure.'}
for name, check in data['checks'].items()}
data['server'] = {key: value for key, value in data['server'].items() if key in SERVER_FIELDS}
data['inventory'] = {section: [{key: value for key, value in row.items() if key in ALLOWED[section]}
for row in rows] for section, rows in data['inventory'].items()}
return data

View File

@@ -0,0 +1,204 @@
"""Read-only local Linux inventory. No networking, sudo, shells or configuration changes."""
import json
import os
from pathlib import Path
import platform
import re
import shlex
import socket
import subprocess
import tempfile
import time
from datetime import datetime, timezone
class CollectionCancelled(Exception):
pass
def run_command(arguments, cancelled=lambda: False, timeout=8):
"""Execute only fixed collector commands from system directories, with bounded output."""
executable = next((str(Path(folder) / arguments[0]) for folder in
('/usr/bin', '/usr/sbin', '/bin', '/sbin')
if (Path(folder) / arguments[0]).is_file()), None)
if executable is None:
raise RuntimeError('Required inventory command is unavailable.')
environment = {'PATH': '/usr/bin:/usr/sbin:/bin:/sbin', 'LC_ALL': 'C',
'LANG': 'C', 'SYSTEMD_PAGER': '', 'SYSTEMD_COLORS': '0'}
with tempfile.TemporaryFile() as output:
process = subprocess.Popen([executable, *arguments[1:]], stdout=output,
stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
env=environment, shell=False)
deadline = time.monotonic() + timeout
try:
while process.poll() is None:
if cancelled():
raise CollectionCancelled('Local collection cancelled.')
if time.monotonic() >= deadline:
raise RuntimeError('Inventory command timed out.')
if os.fstat(output.fileno()).st_size > 10 * 1024 * 1024:
raise RuntimeError('Inventory command exceeded its output limit.')
time.sleep(.05)
if process.returncode:
raise RuntimeError('Inventory command unavailable or access denied.')
output.seek(0)
raw = output.read(10 * 1024 * 1024 + 1)
if len(raw) > 10 * 1024 * 1024:
raise RuntimeError('Inventory command exceeded its output limit.')
return raw.decode('utf-8', errors='replace')
finally:
if process.poll() is None:
process.kill()
process.wait()
def parse_units(text, enabled_text, kind='service'):
enabled = dict(line.split()[:2] for line in enabled_text.splitlines() if len(line.split()) >= 2)
rows = []
for line in text.splitlines():
fields = line.split(None, 4)
if len(fields) < 4 or not fields[0].endswith('.' + kind):
continue
name, loaded, active, sub = fields[:4]
if kind == 'service':
rows.append({'name': name, 'display_name': fields[4] if len(fields) > 4 else name,
'state': 'Running' if active == 'active' and sub == 'running' else active + '/' + sub,
'startup': enabled.get(name, 'Unknown'), 'account': 'Not collected'})
else:
rows.append({'name': name, 'path': 'systemd system timer',
'enabled': active == 'active' or enabled.get(name) in ('enabled', 'enabled-runtime'),
'account': 'Not collected; inspect associated service',
'triggers': 'Not collected; inspect timer calendar',
'state': active + '/' + sub})
return rows
def endpoint(value):
address, port = value.rsplit(':', 1)
return address.strip('[]'), int(port) if port.isdigit() else 0
def parse_connections(text):
rows = []
for line in text.splitlines():
fields = line.split(None, 5)
if len(fields) < 5:
continue
state = {'LISTEN': 'Listen', 'ESTAB': 'Established'}.get(fields[0], fields[0])
local, lp = endpoint(fields[3])
remote, rp = endpoint(fields[4])
process = re.search(r'\("([^"\n]+)",pid=(\d+)', fields[5] if len(fields) > 5 else '')
rows.append({'state': state, 'local_address': local, 'local_port': lp,
'remote_address': remote, 'remote_port': rp,
'pid': int(process[2]) if process else None,
'process': process[1] if process else 'Unavailable to current user',
'direction': 'Unknown'})
return rows
def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, runner=run_command):
if platform.system() != 'Linux':
raise ValueError('Local Linux collection requires Linux Mint or a RHEL-based Linux execution host.')
started = time.monotonic()
data = {'schema_version': '1.0', 'synthetic': False,
'collected_at': datetime.now(timezone.utc).isoformat(),
'server': {'hostname': socket.gethostname(), 'platform': 'linux',
'collection_source': 'Local Linux read-only collection',
'operating_system': platform.system(), 'os_build': platform.release(),
'last_boot': 'Not collected', 'ip_addresses': [], 'network_adapters': [],
'domain': 'Not collected; directory integration requires review'},
'checks': {}, 'inventory': {key: [] for key in
('roles', 'services', 'network', 'tasks', 'software', 'storage', 'certificates')}}
def check_cancel():
if cancelled():
raise CollectionCancelled('Local collection cancelled.')
if time.monotonic() - started > 60:
raise RuntimeError('Local collection exceeded its overall time limit.')
def command(args):
check_cancel()
return runner(args, cancelled=cancelled, timeout=min(8, max(1, 60 - (time.monotonic() - started))))
def check(name, operation, message, status='partial'):
check_cancel()
stage('Collecting local Linux ' + name + '…')
try:
operation()
data['checks'][name] = {'status': status, 'message': message}
except CollectionCancelled:
raise
except (OSError, ValueError, RuntimeError, KeyError, IndexError, StopIteration):
data['checks'][name] = {'status': 'failed', 'message': 'Check could not complete: missing command, restricted access, unsupported output or timeout. No synthetic replacement used.'}
def system():
release = {}
for line in Path('/etc/os-release').read_text().splitlines():
if '=' in line:
key, value = line.split('=', 1)
release[key] = ' '.join(shlex.split(value))
data['server']['operating_system'] = release.get('PRETTY_NAME', 'Linux (distribution unknown)')
boot = next(line.split()[1] for line in Path('/proc/stat').read_text().splitlines() if line.startswith('btime '))
data['server']['last_boot'] = datetime.fromtimestamp(int(boot), timezone.utc).isoformat()
check('system', system, 'Local OS, kernel and boot time observed; domain membership not inspected.')
def interfaces():
adapters = json.loads(command(['ip', '-j', 'address', 'show']))
data['server']['network_adapters'] = [row['ifname'] for row in adapters]
data['server']['ip_addresses'] = [address['local'] for row in adapters for address in row.get('addr_info', [])]
check('interfaces', interfaces, 'Addresses observed from local interfaces; no name resolution or remote probes.', 'complete')
def units(kind):
listing = command(['systemctl', 'list-units', '--all', '--type=' + kind, '--no-legend', '--plain', '--no-pager'])
enabled = command(['systemctl', 'list-unit-files', '--type=' + kind, '--no-legend', '--no-pager'])
data['inventory']['services' if kind == 'service' else 'tasks'] = parse_units(listing, enabled, kind)
check('services', lambda: units('service'), 'System systemd units observed; per-service accounts, user units and executables not collected.')
check('tasks', lambda: units('timer'), 'Loaded system timers observed; unloaded timers, schedules, cron/anacron, user timers and application schedulers require manual review.')
def network():
data['inventory']['network'] = parse_connections(command(['ss', '-H', '-n', '-t', '-a', '-p']))
check('network', network, 'TCP snapshot only. Process visibility is limited by permissions; direction, historical activity, UDP and other namespaces not verified.')
def software():
rpm = any((Path(folder) / 'rpm').is_file() for folder in ('/usr/bin', '/bin'))
args = ['rpm', '-qa', '--qf', '%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\n'] if rpm else ['dpkg-query', '-W', '-f=${db:Status-Status}\t${binary:Package}\t${Version}\n']
for line in command(args).splitlines():
parts = line.split('\t')
if len(parts) != 3:
raise ValueError('Unsupported package output')
if not rpm and parts[0] != 'installed':
continue
data['inventory']['software'].append({'name': parts[0] if rpm else parts[1],
'version': parts[1] if rpm else parts[2], 'publisher': parts[2] if rpm else 'Not collected'})
check('software', software, 'RPM or dpkg database queried read-only. Containers, manually installed applications and language package environments not covered.')
def storage():
# Do not stat mounted filesystems: stale NFS or FUSE could block the worker.
for line in Path('/proc/self/mountinfo').read_text().splitlines():
before, after = line.split(' - ', 1)
fields, fs = before.split(), after.split()
unescape = lambda value: re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), value)
data['inventory']['storage'].append({'type': 'Remote Mount' if fs[0] in ('nfs', 'nfs4', 'cifs', 'smb3') else 'Mounted Filesystem',
'name': unescape(fs[1]), 'path': unescape(fields[4]), 'filesystem': fs[0],
'capacity_gib': None, 'free_gib': None})
check('storage', storage, 'Mounts visible in this process namespace observed; capacity/free space and NFS/Samba export configuration not collected. Remote filesystems were not accessed.')
for key, message in {
'roles': 'Linux workloads require service/package/configuration correlation; no Windows role equivalent inferred.',
'certificates': 'Application-specific certificate locations not inspected; no private keys read.',
'shares': 'NFS and Samba export configuration and consumers not inspected.',
'directory': 'Directory integration, external service accounts and identity dependencies not inspected.',
}.items():
data['checks'][key] = {'status': 'not_collected', 'message': message}
check_cancel()
return data
def main():
"""Headless local collection for testing without Qt or Windows modules."""
import argparse
parser = argparse.ArgumentParser(description='Collect read-only inventory of this Linux host. No remote connection is made.')
parser.add_argument('--output', required=True, help='Local JSON destination (contains host inventory)')
args = parser.parse_args()
try:
result = collect_local_linux()
# Exclusive creation prevents accidentally replacing an existing report.
with open(args.output, 'x', encoding='utf-8') as destination:
json.dump(result, destination, indent=2)
print('Local inventory saved. Review per-check statuses before making retirement decisions.')
except (OSError, ValueError, RuntimeError):
parser.exit(1, 'Collection/export failed. Check Linux platform, destination permissions and whether the destination already exists.\n')
if __name__ == '__main__':
main()

20
server_buddy/main.py Normal file
View File

@@ -0,0 +1,20 @@
import sys
from server_buddy.core.startup import desktop_error
def main():
error = desktop_error()
if error:
print(error, file=sys.stderr)
return 1
from PySide6.QtWidgets import QApplication
from server_buddy.ui.window import MainWindow
app = QApplication(sys.argv)
app.setApplicationName('Server Buddy')
window = MainWindow()
window.show()
return app.exec()
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,23 @@
import html
import json
from pathlib import Path
MANUAL_STEPS = ["Confirm application and business owners and obtain retirement approval.",
"Review historical monitoring, DNS, load balancers, backups and firewall logs.",
"Validate service accounts, scheduled workloads and SMB/NFS/Samba consumers with owners.",
"Resolve failed or missing checks and verify rollback and retention requirements."]
def export_json(path, data, assessment):
Path(path).write_text(json.dumps({"inventory_result": data, "assessment": assessment}, indent=2), encoding="utf-8")
def export_html(path, data, assessment):
escape = lambda value: html.escape(str(value))
content = "<h1>Server Buddy assessment</h1><p>SYNTHETIC DEMO — no real collection performed.</p>" if data["synthetic"] else "<h1>Server Buddy assessment</h1><p>IMPORTED INVENTORY — source assertions not independently verified.</p>"
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection':
content = '<h1>Server Buddy assessment</h1><p>LOCAL LINUX INVENTORY — read-only snapshot of the execution host.</p>'
content += "<p>Heuristic requiring administrator review. This report does not authorize retirement.</p>"
content += f"<p>Risk: {escape(assessment['level'])} ({assessment['score']}/100)</p>"
for title, value in [("Server overview", data["server"]), ("Collection timestamp", data["collected_at"]), ("Inventory", data["inventory"]), ("Dependencies and risk findings", assessment["findings"]), ("Collection checks / missing evidence", data["checks"])]:
content += f"<h2>{title}</h2><pre>{escape(json.dumps(value, indent=2))}</pre>"
content += "<h2>Recommended manual verification</h2><ul>" + "".join(f"<li>{escape(s)}</li>" for s in MANUAL_STEPS) + "</ul>"
Path(path).write_text('<!doctype html><html lang="en"><meta charset="utf-8"><title>Server Buddy</title><style>body{font:16px system-ui;max-width:1100px;margin:40px auto;padding:20px}pre{white-space:pre-wrap;background:#eee;padding:16px}</style>' + content + '</html>', encoding="utf-8")

View File

@@ -0,0 +1 @@

318
server_buddy/ui/window.py Normal file
View File

@@ -0,0 +1,318 @@
import platform
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
QPushButton, QListWidget, QStackedWidget, QLineEdit, QTabWidget, QTableView,
QFileDialog, QMessageBox, QComboBox, QHeaderView)
from server_buddy.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
from server_buddy.collectors.importer import load_inventory
from server_buddy.collectors.linux import collect_local_linux, CollectionCancelled
from server_buddy.core.validation import validate_hostname
from server_buddy.analyzers.assessment import assess
from server_buddy.reporting.export import export_html, export_json
class CollectionJob(QThread):
stage = Signal(str)
result = Signal(dict)
failed = Signal(str)
def __init__(self, hostname, parent=None, inventory_path=None, local_linux=False):
super().__init__(parent)
self.hostname = hostname
self.inventory_path = inventory_path
self.local_linux = local_linux
def run(self):
try:
self.stage.emit('Loading inventory JSON…' if self.inventory_path else 'Loading synthetic inventory…')
if self.local_linux:
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
else:
data = load_inventory(self.inventory_path) if self.inventory_path else collect_demo(self.hostname)
if self.isInterruptionRequested():
raise CollectionCancelled('Collection cancelled; previous assessment retained.')
self.stage.emit('Evaluating inventory evidence…')
self.result.emit(data)
except CollectionCancelled:
self.failed.emit('Collection cancelled; previous assessment retained.')
except ValueError as exc:
self.failed.emit(str(exc))
except Exception:
self.failed.emit('Collection could not complete. Verify the selected environment, inventory format and file access. No synthetic replacement was used.')
class SearchTable(QWidget):
def __init__(self, rows):
super().__init__()
layout = QVBoxLayout(self)
search = QLineEdit(placeholderText='Search all columns…')
layout.addWidget(search)
view = QTableView()
model = QStandardItemModel(self)
columns = list(dict.fromkeys(key for row in rows for key in row))
model.setHorizontalHeaderLabels([key.replace('_', ' ').title() for key in columns])
for row in rows:
items = []
for key in columns:
value = row.get(key)
item = QStandardItem()
item.setData('' if value is None else value if isinstance(value, (str, int, float)) else str(value), Qt.ItemDataRole.DisplayRole)
item.setEditable(False)
items.append(item)
model.appendRow(items)
proxy = QSortFilterProxyModel(self)
proxy.setSourceModel(model)
proxy.setFilterKeyColumn(-1)
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
search.textChanged.connect(proxy.setFilterFixedString)
view.setModel(proxy)
view.setSortingEnabled(True)
view.setAlternatingRowColors(True)
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
view.horizontalHeader().setStretchLastSection(True)
view.resizeColumnsToContents()
layout.addWidget(view)
if not rows:
layout.addWidget(QLabel('No records in this inventory. This is not proof of absence.'))
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle('Server Buddy — Retirement Assessment')
self.resize(1280, 820)
self.setMinimumSize(760, 540)
self.job = None
self.data = collect_demo(DEMO_HOST)
self.assessment = assess(self.data)
self.history = [self.history_row()]
self.setStyleSheet('''
QWidget { background:#121b29; color:#e4eaf2; font-size:13px; }
QLabel#title { font-size:24px; font-weight:600; }
QLabel#banner { background:#46391b; color:#ffde8a; padding:12px; }
QPushButton { background:#236a9b; border:0; border-radius:5px; padding:10px 16px; }
QPushButton:hover { background:#3284ba; } QPushButton:disabled { background:#394355; }
QLineEdit,QComboBox { background:#202e40; border:1px solid #40516a; padding:9px; }
QListWidget { background:#182334; border:0; min-width:170px; }
QListWidget::item { padding:16px 10px; } QListWidget::item:selected { background:#235477; }
QTabBar::tab { padding:9px; background:#243348; } QTabBar::tab:selected { background:#236a9b; }
QHeaderView::section { background:#293c53; padding:8px; border:0; }
QTableView { alternate-background-color:#1b293c; gridline-color:#304057; }
''')
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
self.nav = QListWidget()
self.nav.addItems(['Dashboard', 'Server Analyzer', 'Dependency Explorer', 'Assessment History', 'Reports', 'Settings'])
self.nav.setMaximumWidth(220)
layout.addWidget(self.nav)
right = QVBoxLayout()
layout.addLayout(right, 1)
banner = self.banner = QLabel('SYNTHETIC DEMO • Offline fixtures only • No infrastructure is contacted')
banner.setObjectName('banner')
banner.setWordWrap(True)
right.addWidget(banner)
self.pages = QStackedWidget()
right.addWidget(self.pages)
self.build_pages()
self.nav.currentRowChanged.connect(self.pages.setCurrentIndex)
self.nav.setCurrentRow(0)
def page(self, title):
widget = QWidget()
layout = QVBoxLayout(widget)
label = QLabel(title)
label.setObjectName('title')
layout.addWidget(label)
self.pages.addWidget(widget)
return layout
def history_row(self):
return {'hostname': self.data['server']['hostname'], 'collected_at': self.data['collected_at'],
'risk': self.assessment['level'], 'source': 'Synthetic fixture' if self.data['synthetic'] else self.data['server'].get('collection_source', 'Imported inventory')}
def build_pages(self):
dash = self.page('Retirement assessment dashboard')
self.metrics = QLabel()
self.metrics.setWordWrap(True)
dash.addWidget(self.metrics)
dash.addWidget(QLabel('Recently assessed servers — see Source column'))
self.recent_area = QVBoxLayout()
dash.addLayout(self.recent_area)
analyzer = self.page('Server Analyzer')
analyzer.addWidget(QLabel('Target platform (demo inventory)'))
self.target = QComboBox()
self.target.addItems(['Windows Server 2016 onward', 'RHEL-based Linux'])
self.target.currentIndexChanged.connect(self.target_changed)
analyzer.addWidget(self.target)
analyzer.addWidget(QLabel('Server Hostname or FQDN'))
row = QHBoxLayout()
self.hostname = QLineEdit(DEMO_HOST)
row.addWidget(self.hostname)
self.analyze_button = QPushButton('Analyze Server')
self.analyze_button.clicked.connect(self.start_collection)
self.hostname.returnPressed.connect(self.start_collection)
row.addWidget(self.analyze_button)
analyzer.addLayout(row)
self.local_button = QPushButton('Analyze This Linux PC — read-only')
self.local_button.setEnabled(platform.system() == 'Linux')
self.local_button.clicked.connect(self.start_local_collection)
analyzer.addWidget(self.local_button)
note = QLabel('Local collection assesses the computer running Server Buddy. No remote hostname is used. Mint testing uses dpkg; RHEL-based Linux uses RPM. No sudo required.')
note.setWordWrap(True)
analyzer.addWidget(note)
self.cancel_button = QPushButton('Cancel collection')
self.cancel_button.setEnabled(False)
self.cancel_button.clicked.connect(self.cancel_collection)
analyzer.addWidget(self.cancel_button)
self.import_button = QPushButton('Import Windows / Linux inventory JSON')
self.import_button.clicked.connect(self.import_inventory)
analyzer.addWidget(self.import_button)
self.status = QLabel('Ready. Demo supports only ' + DEMO_HOST)
self.status.setWordWrap(True)
analyzer.addWidget(self.status)
self.tabs = QTabWidget()
self.tabs.setUsesScrollButtons(True)
analyzer.addWidget(self.tabs)
deps = self.page('Dependency Explorer')
deps.addWidget(QLabel('Evidence indicates possible consumers; confirm ownership and activity over time.'))
self.deps_area = QVBoxLayout()
deps.addLayout(self.deps_area)
history = self.page('Assessment History')
history.addWidget(QLabel('Session history only. Source column distinguishes synthetic and imported records.'))
self.history_area = QVBoxLayout()
history.addLayout(self.history_area)
reports = self.page('Reports')
reports.addWidget(QLabel('Export the current assessment with source labels, missing evidence and manual checks. Review inventory for sensitive content before sharing.'))
for label, fmt in [('Save HTML report', 'html'), ('Export JSON', 'json')]:
button = QPushButton(label)
button.clicked.connect(lambda checked=False, kind=fmt: self.save_report(kind))
reports.addWidget(button)
reports.addStretch()
settings = self.page('Settings')
settings.addWidget(QLabel('Execution environment for Analyze Server (JSON import is always offline)'))
self.environment = QComboBox()
self.environment.addItems(['Offline synthetic demo', 'Windows execution host (future phase)', 'This Linux PC — read-only'])
settings.addWidget(self.environment)
info = QLabel(f'Local platform: {platform.system()}\nWindows remoting is not implemented in Phase 1.\nPlanned targets: Windows Server 2016 onward and RHEL-based Linux.\nWindows collection: authorized Windows execution host. Linux collection: local Mint / RHEL-based inventory now available; remote SSH is planned.\nWindows and Linux desktops can import either inventory format.\nNo credentials are requested or stored. No remote configuration is performed.')
info.setWordWrap(True)
settings.addWidget(info)
settings.addStretch()
self.refresh()
def replace_table(self, layout, rows):
while layout.count():
item = layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
layout.addWidget(SearchTable(rows))
def refresh(self):
high = sum(f['severity'] in ('high', 'critical') for f in self.assessment['findings'])
self.metrics.setText(f'Total servers assessed: {len({row["hostname"] for row in self.history})} • Assessments requiring review: {len({row["hostname"] for row in self.history})} • High-risk findings (current assessment): {high}\nHeuristic risk: {self.assessment["level"].upper()} ({self.assessment["score"]}/100). Administrator review required; never an automatic retirement decision.')
self.replace_table(self.recent_area, [self.history[-1]])
self.replace_table(self.history_area, self.history)
self.replace_table(self.deps_area, self.assessment['findings'])
while self.tabs.count():
widget = self.tabs.widget(0)
self.tabs.removeTab(0)
widget.deleteLater()
overview = [{'property': k.replace('_', ' ').title(), 'value': str(v)} for k, v in self.data['server'].items()]
overview += [{'property': 'Collection timestamp (reported by source)', 'value': self.data['collected_at']}]
overview += [{'property': f'Check: {k}', 'value': f'{v["status"]}: {v["message"]}'} for k, v in self.data['checks'].items()]
self.tabs.addTab(SearchTable(overview), 'Overview')
for key, label in [('roles', 'Roles & Workloads'), ('services', 'Services'), ('network', 'Network Connections'), ('tasks', 'Scheduled Tasks / Timers'), ('software', 'Software'), ('storage', 'Storage & Shares'), ('certificates', 'Certificates')]:
self.tabs.addTab(SearchTable(self.data['inventory'][key]), label)
self.tabs.addTab(SearchTable(self.assessment['findings']), 'Dependencies')
self.tabs.addTab(SearchTable(self.assessment['findings']), 'Risk Assessment')
def target_changed(self, index):
self.hostname.setText(LINUX_DEMO_HOST if index else DEMO_HOST)
def import_inventory(self):
if self.job is not None:
return
path, _ = QFileDialog.getOpenFileName(self, 'Import inventory JSON', '', 'JSON (*.json)')
if path:
self.start_job('', path)
def cancel_collection(self):
if self.job is not None:
self.job.requestInterruption()
self.cancel_button.setEnabled(False)
self.status.setText('Cancelling collection…')
def start_local_collection(self):
if self.job is not None:
return
if platform.system() != 'Linux':
self.status.setText('Local Linux collection requires Linux. Windows collection is not implemented yet; use demo or JSON import.')
return
self.start_job('', local_linux=True)
def start_job(self, hostname, inventory_path=None, local_linux=False):
self.analyze_button.setEnabled(False)
self.import_button.setEnabled(False)
self.local_button.setEnabled(False)
self.cancel_button.setEnabled(True)
self.status.setText('Starting local read-only Linux inventory…' if local_linux else 'Loading offline inventory…')
self.job = CollectionJob(hostname, self, inventory_path=inventory_path, local_linux=local_linux)
self.job.stage.connect(self.status.setText)
self.job.result.connect(self.collection_done)
self.job.failed.connect(lambda message: self.status.setText('Collection failed: ' + message))
self.job.finished.connect(self.job_finished)
self.job.start()
def start_collection(self):
if self.job is not None:
return
if self.environment.currentIndex() == 2:
self.start_local_collection()
return
try:
hostname = validate_hostname(self.hostname.text())
if self.environment.currentIndex() != 0:
raise ValueError('Windows execution host collection is not implemented in Phase 1. Select Offline synthetic demo in Settings.')
except ValueError as exc:
self.status.setText(str(exc))
return
self.start_job(hostname)
def collection_done(self, data):
self.data = data
self.assessment = assess(data)
self.history.append(self.history_row())
self.refresh()
local = data['server'].get('collection_source') == 'Local Linux read-only collection'
self.banner.setText('SYNTHETIC DEMO • Offline fixtures only • No infrastructure is contacted' if data['synthetic'] else 'LOCAL LINUX INVENTORY • Read-only snapshot • Review partial and missing checks' if local else 'IMPORTED INVENTORY • Source assertions not independently verified • No remote connection made')
self.status.setText('Complete — synthetic fixture loaded; no real server was assessed.' if data['synthetic'] else 'Complete — local Linux inventory collected. Review partial/failed checks in Overview, then save a report from Reports.' if local else 'Complete — imported inventory analyzed. Collection timestamp and check statuses come from the file.')
def job_finished(self):
self.job.deleteLater()
self.job = None
self.analyze_button.setEnabled(True)
self.import_button.setEnabled(True)
self.local_button.setEnabled(platform.system() == 'Linux')
self.cancel_button.setEnabled(False)
def save_report(self, kind):
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'server-buddy-assessment.{kind}', f'{kind.upper()} (*.{kind})')
if not path:
return
if not path.lower().endswith('.' + kind):
path += '.' + kind
try:
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
QMessageBox.information(self, 'Report saved', 'Assessment saved locally.')
except OSError:
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')
def closeEvent(self, event):
if self.job is not None and self.job.isRunning():
self.status.setText('Collection is running. Use Cancel collection, then close when the worker finishes.')
event.ignore()
else:
event.accept()