From 365f97942ae016b161e68e35ef6c1bb1f6dca4b6 Mon Sep 17 00:00:00 2001 From: oceans2alaska Date: Fri, 30 Oct 2026 09:46:01 -0700 Subject: [PATCH] Add Server Buddy desktop app with local Linux assessment and reporting --- .gitignore | 27 ++ README.md | 68 ++++ docs/DEVELOPMENT.md | 29 ++ docs/IMPLEMENTATION_PLAN.md | 24 ++ docs/PLATFORMS.md | 34 ++ powershell/README.md | 5 + requirements.txt | 2 + schemas/inventory-v1.schema.json | 220 ++++++++++++ server_buddy/__init__.py | 1 + server_buddy/analyzers/__init__.py | 1 + server_buddy/analyzers/assessment.py | 44 +++ server_buddy/assets/demo_inventory.json | 140 ++++++++ server_buddy/assets/demo_linux_inventory.json | 129 +++++++ server_buddy/collectors/__init__.py | 1 + server_buddy/collectors/demo.py | 13 + server_buddy/collectors/importer.py | 47 +++ server_buddy/collectors/linux.py | 204 +++++++++++ server_buddy/main.py | 20 ++ server_buddy/models/__init__.py | 1 + server_buddy/reporting/__init__.py | 1 + server_buddy/reporting/export.py | 23 ++ server_buddy/ui/__init__.py | 1 + server_buddy/ui/window.py | 318 ++++++++++++++++++ tests/fixtures/synthetic_inventory.json | 139 ++++++++ tests/test_core.py | 55 +++ tests/test_import.py | 49 +++ tests/test_linux.py | 89 +++++ tests/test_startup.py | 17 + tests/test_ui.py | 87 +++++ 29 files changed, 1789 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/IMPLEMENTATION_PLAN.md create mode 100644 docs/PLATFORMS.md create mode 100644 powershell/README.md create mode 100644 requirements.txt create mode 100644 schemas/inventory-v1.schema.json create mode 100644 server_buddy/__init__.py create mode 100644 server_buddy/analyzers/__init__.py create mode 100644 server_buddy/analyzers/assessment.py create mode 100644 server_buddy/assets/demo_inventory.json create mode 100644 server_buddy/assets/demo_linux_inventory.json create mode 100644 server_buddy/collectors/__init__.py create mode 100644 server_buddy/collectors/demo.py create mode 100644 server_buddy/collectors/importer.py create mode 100644 server_buddy/collectors/linux.py create mode 100644 server_buddy/main.py create mode 100644 server_buddy/models/__init__.py create mode 100644 server_buddy/reporting/__init__.py create mode 100644 server_buddy/reporting/export.py create mode 100644 server_buddy/ui/__init__.py create mode 100644 server_buddy/ui/window.py create mode 100644 tests/fixtures/synthetic_inventory.json create mode 100644 tests/test_core.py create mode 100644 tests/test_import.py create mode 100644 tests/test_linux.py create mode 100644 tests/test_startup.py create mode 100644 tests/test_ui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18ac40a --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +build/ +dist/ +reports/ +*.spec + +# Local tooling and credentials +.agents/ +.aws/ +.codex/ +.env +.env.* +!.env.example +*.pem +*.key +*.pfx +*.p12 + +# Local assessment artifacts (may contain infrastructure details) +server-buddy-assessment.* +server-buddy-local-*.json +core +core.* +*.bundle diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a40e0f --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Server Buddy + +Desktop retirement assessment tool for Windows Server and Linux administrators. Python 3.11+, PySide6 and versioned JSON. **Local read-only Linux Mint/RHEL-based collection, Windows/Linux synthetic demos, offline JSON import and HTML/JSON reporting are available.** Remote collection and live Windows collection remain incomplete. No real enterprise infrastructure is contacted automatically. + +## Linux Mint setup + +```bash +sudo apt update +sudo apt install python3 python3-venv libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -r requirements.txt +python -m server_buddy.main +``` + +Run from the project root in a graphical desktop session. If Qt mentions the `xcb` cursor library, install `libxcb-cursor0`; reinstalling Python packages does not supply this system library. The launcher diagnoses this common issue before starting Qt. + +On Windows 10/11, create a virtual environment with `py -3 -m venv .venv`, activate `.venv\Scripts\activate`, install requirements and run the same module command. Windows desktop execution still needs validation. Local Linux collection is disabled on Windows; demo and inventory import remain available. + +## Test a real assessment on your Mint PC + +1. Close any already-open Server Buddy window and restart with `python -m server_buddy.main` to load the updated code. +2. Open **Server Analyzer** and click **Analyze This Linux PC — read-only**. This button assesses the computer running the application, irrespective of the demo target and hostname fields. It never connects to a remote hostname. +3. Watch the collection stages. Use **Cancel collection** if needed; cancellation retains the previous assessment. Background commands have time/output limits. +4. Confirm the banner says **LOCAL LINUX INVENTORY**. Overview shows the actual local OS, kernel, boot time and per-check statuses. Review Services, Network Connections, Scheduled Tasks / Timers, Software, Storage, Dependencies and Risk Assessment. Tables are searchable and sortable. +5. Open **Reports**, save an HTML report and open the saved file in a browser. JSON export is also available. Local reports are labeled as local snapshots rather than imported or synthetic data. + +Alternatively select **This Linux PC — read-only** under Settings, then use **Analyze Server**. The local mode ignores the hostname field. Local collection requires no sudo, passwords or remote setup. It does not install packages, restart services, change configuration, resolve remote names or read private keys, service command lines or environment variables. + +The collector uses systemd, `ss`, `ip`, RPM where available and dpkg on Mint/Debian-derived hosts. Commands unavailable in containers or restricted environments are recorded as failures, with no synthetic replacement. Results describe the process's local namespace and permissions, which may differ from the whole host. + +### Current local coverage and limits + +- OS distribution, kernel, boot time, interfaces and addresses. +- Loaded system service state/startup; accounts, user services and executable details remain incomplete. +- TCP snapshot and process attribution where visible. Direction and historical/UDP activity are unverified. +- Loaded system timers; cron/anacron, user timers, unloaded timers and trigger configuration require review. +- RPM/dpkg software; containers and manually installed applications are outside coverage. +- Mount table, including remote mount references. Capacity/free space and NFS/Samba exports are not yet collected. Mounted remote filesystems are not accessed. +- Application certificates, directory integration and workload role inference are explicitly not collected. + +The heuristic score is preliminary and requires administrator review. Routine Linux services are not automatically classified as active application workloads; unclassified services contribute uncertainty. Neither missing connections nor a low score proves retirement safety. + +## Headless local Linux inventory + +The local collector can also produce JSON without PySide6 or jsonschema (Python standard library only): + +```bash +python3 -m server_buddy.collectors.linux --output /tmp/my-local-inventory.json +``` + +Use a new output filename; the CLI will not overwrite an existing file. It reads only the local execution host and writes only the requested report file. Import the JSON in either desktop using **Import Windows / Linux inventory JSON**. This provides a manual offline workflow for future RHEL validation without GUI packages on the assessed host. Do not treat Mint testing as validation of every RHEL release. + +## Synthetic and offline testing + +The target selector chooses **Windows Server 2016 onward** (`demo-web-01.example.test`) or **RHEL-based Linux** (`demo-rhel-01.example.test`) synthetic fixtures. Click Analyze Server in demo mode to load the selected fixture. All fixture data and timestamps remain clearly labeled. Demo mode rejects other hostnames. + +JSON import validates the v1 collection schema (or a report wrapper), enforces a 10 MiB input limit, recalculates risk and strips task/service command lines and external diagnostic details. Imported timestamps/check statuses are not independently verified. Review all report content before sharing; inventory names, paths and addresses may be sensitive. History is session-only and labels each record's source. + +## Tests + +```bash +python -m unittest discover -s tests -v +``` + +Qt tests run offscreen automatically. Tests cover schema validation, source labeling, Linux service/network/package parsing, missing commands, cancellation/timeouts, local GUI workflow and report escaping. The current development environment passed all 21 tests; a real local smoke collection passed schema validation and generated HTML/JSON. In its restricted environment, systemd and interface commands failed visibly, while package, TCP and mount collection ran. GUI desktop appearance and Windows/RHEL execution still need platform validation. + +See [implementation plan](docs/IMPLEMENTATION_PLAN.md), [platform support](docs/PLATFORMS.md) and [developer guide](docs/DEVELOPMENT.md). Future Windows collection will use existing authorized PowerShell remoting from a Windows execution host, never Windows modules on Linux. Packaging, Windows inventory and remote SSH collection remain later work. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..7816185 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,29 @@ +# Development guide + +Run commands from the repository root. Use a Python 3.11+ virtual environment and requirements.txt. Start with `python -m server_buddy.main`. Assets are resolved relative to the module, independent of the user's working directory; tests use repository-relative schema/fixture paths. + +## Phase 1 behavior + +Qt widgets stay on the GUI thread. CollectionJob loads the bundled JSON in a QThread and delivers stage/result/error signals. Analyze and import buttons are disabled until the worker finishes; closing an active job is deferred to avoid destroying a running thread. Demo loading is finite local I/O; production timeout/cancellation and process lifecycle management are Phase 3 requirements. No subprocesses are launched in Phase 1. + +Tables use Qt proxy filtering across all columns with native numeric sorting. Session history records repeated fixture loads without changing fixture timestamps. Dashboard counts unique hostnames from fixture/import history, while history counts runs. High-risk findings refer to the current assessment. Two OS-specific fixtures supply the demo; user input cannot relabel synthetic evidence as a live server assessment. + +Risk rules live in analyzers/assessment.py. Each finding contains classification, evidence, severity and points. Score is the sum capped at 100: 0 informational, 1–29 low, 30–59 medium, 60–89 high, 90–100 critical. These preliminary rules are demonstration logic, not a validated production risk model. Severity is an individual finding's severity, while overall risk is the aggregate. Observation limits always contribute uncertainty. + +Collection JSON uses schemas/inventory-v1.schema.json. Export JSON wraps the collection as inventory_result and includes the derived assessment. Do not treat the report wrapper as collection JSON. Live collection must enforce the schema at the boundary and tighten field schemas as PowerShell contracts are implemented. Never replace failed checks with synthetic values. + +## Verification in this development environment + +Python 3.12.3 is available. `python3 -m unittest discover -s tests -v`: four tests passed; schema and Qt smoke tests skipped because jsonschema and PySide6 are absent. An isolated installation under /tmp failed due to package server DNS/network resolution. GUI launch and visual appearance have not been verified here. Run the documented tests after installing dependencies on Linux Mint; also test a Windows desktop before distribution. + +No PowerShell collection scripts or production collector exist yet, so there is no real PowerShell output to validate. Phase 1 requires no remote accounts, remoting configuration, or administrative privilege. + +## Follow-up verification + +After dependencies were installed in the project virtual environment: `.venv/bin/python -m unittest discover -s tests -v` passed all 12 tests, including two offscreen Qt tests, schema validation, Linux inventory import/NFS evidence, sensitive-field exclusion and startup diagnostics. Source compilation passed. Normal X11 startup is blocked by the confirmed missing `libxcb-cursor.so.0`; the launcher prints Mint installation instructions. Windows execution and real RHEL collection remain untested/unimplemented as described in PLATFORMS.md. + +## Local Linux collector + +`collectors/linux.py` executes fixed argument lists from system binary directories with a restricted environment, no shell or user hostname, DEVNULL stderr and no sudo. Each process is polled for cancellation, an eight-second time limit and a 10 MiB output limit; the process is killed and reaped on interruption/failure. Overall collection is bounded to approximately 60 seconds for command execution. Local /proc and /etc/os-release reads do not access mounted remote filesystems. There is no arbitrary command UI. Collection source is included in server metadata; JSON import strips that source field and treats imported evidence as unverified. GUI workers retain the previous assessment on cancellation. New local checks intentionally return partial/missing statuses rather than asserting full coverage. + +Verification: 21 unit/UI tests passed, plus a real local read-only smoke collection validated against the JSON schema with HTML/JSON generation. Tests use both RPM and dpkg fixtures, check service/IPv6 parsing and unknown-service classification, command timeout/reaping, cancellation, and source labels. Real RHEL and Windows execution is still pending. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..28fa36c --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,24 @@ +# Implementation plan + +Repository inspection: empty project directory with reserved environment directories; no existing application or repository instructions. + +1. **Phase 1 (implemented):** offline PySide6 dashboard, six navigation pages, ten inventory tabs, hostname validation, background demo loading, searchable/sortable tables, session history, explicit execution selection, synthetic fixture, local demo exports and preliminary evidence/risk presentation. +2. **Phase 2:** independent read-only PowerShell modules for system/roles, services, TCP/processes, scheduled tasks, registry software, volumes/shares and public certificate metadata. Versioned JSON envelope with per-check statuses. Optional AD enrichment interface; no AD requirement. +3. **Phase 3:** Windows execution-host collector using existing authorized Invoke-Command sessions. Resolve trusted script paths; pass validated hostname as an argument to a fixed script via subprocess argument lists (never shell=True). Enforce timeouts, cancellation, process cleanup, bounded output and schema validation. Linux remains offline. No remoting setup. +4. **Phase 4:** expand transparent weighted rules, role/process mappings, service account and inbound connection correlation; distinguish observed activity from potential dependencies and unknown evidence. Review critical infrastructure role rules before live use. +5. **Phase 5:** production reports, redaction of sensitive service/task arguments, failed-check visibility, export validation and report metadata. Existing Phase 1 exports contain synthetic records only. +6. **Phase 6:** collector contract tests, PowerShell validation on Windows, failure/timeout tests, Windows packaging and signed release guidance. + +## Structure and boundaries + +`server_buddy/ui`: Qt views and background worker. `core`: input validation and future orchestration. `collectors`: platform-specific collection backends. `models`: future typed schema adapters. `analyzers`: evidence and heuristic rules. `reporting`: local exports. `assets`: bundled offline fixture. `powershell`: future Windows-only scripts. `schemas`: collection contract. `tests`: fixture, core and optional Qt tests. + +History is session-only; timestamps are original fixture timestamps. No SQLite or remote collection in Phase 1. A missing check never means a negative result. Synthetic demo is bound to a reserved example.test hostname and documentation-only IP addresses. Export pipeline must gain production redaction before any live collection is enabled. + +## Scope expansion: Linux targets and desktop startup + +The target scope now includes Windows Server 2016 onward and RHEL-based Linux, with either Windows or Mint as the reporting desktop. Added a RHEL synthetic dataset, target selector, background schema-validated JSON intake, OS-neutral report labels, NFS/Samba dependency rules, and a pre-Qt diagnostic for the missing Mint xcb cursor library. Live collection remains outside the current Phase 1 implementation. See PLATFORMS.md for transport boundaries, Linux inventory mappings and the required test matrix. + +## Local Linux testing implementation + +Added real read-only local collection for Mint and RPM-based Linux, a direct Analyze This Linux PC action, Settings mode, cancellation, command time/output bounds and headless JSON export. Real local results have distinct banners/history/report labels. Missing service identities, user/cron tasks, share exports, disk capacity, role correlation and certificate checks remain explicit. Both package formats are covered by parser tests; full RHEL and Windows validation remains pending. No remote transport is enabled. diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md new file mode 100644 index 0000000..c5d06e7 --- /dev/null +++ b/docs/PLATFORMS.md @@ -0,0 +1,34 @@ +# Platform support and collection plan + +## Current functionality + +| Desktop | Windows inventory reporting | RHEL-based inventory reporting | Live collection | +| --- | --- | --- | --- | +| Linux Mint, Python 3.11+ | Synthetic demo and schema-validated local JSON import | Synthetic demo and schema-validated local JSON import | Local read-only Linux collector on Linux; no remote collection | +| Windows 10/11, Python 3.11+ | Same application code; Windows desktop testing pending | Same application code; Windows desktop testing pending | Not implemented | + +**Update:** Local Linux Mint/RHEL-based collection is now implemented via `collectors/linux.py`, with direct GUI and headless CLI entry points. It uses RPM on hosts where RPM is available and dpkg on Mint. Mint-like package parsing and mocked RPM output are tested; real RHEL release validation remains pending. See README for exact coverage and missing checks. Windows live collection remains unimplemented. + +Qt is a desktop dependency, not a dependency to install on assessed servers. Headless RHEL servers will produce JSON through a separate collector and do not need PySide6. Reporting does not require Windows administrative modules on Linux. + +## Target collection support + +Windows targets: Windows Server 2016 onward. Use read-only PowerShell compatible with Windows PowerShell 5.1 from an existing authorized Windows execution host. Linux Mint can import that execution host's JSON. A future transport to a Windows execution host must be explicit and authenticated; never run Windows administration modules natively on Linux. Feature availability and permissions vary, so every module reports complete/partial/failed/not_collected separately. Test each supported Windows Server release before claiming collection compatibility. + +RHEL-based targets: RHEL and compatible distributions, initially validate supported enterprise 8/9/10 families individually; older versions require explicit compatibility testing. The offline local collector can run on the Linux execution host and export JSON for manual transfer. Future remote collection will use existing authorized SSH. No agent installation, automatic SSH enablement, privilege escalation or remote configuration. Windows desktop SSH support must be checked at runtime. Offline JSON remains the common fallback. + +Linux inventory requirements: + +- `/etc/os-release`, kernel, boot time, addresses, interfaces and directory integration evidence. +- systemd services with state, unit path and execution identity; no secret-bearing environment or command arguments. +- `ss` TCP observations, with unprivileged process attribution explicitly marked incomplete where necessary. +- system and accessible user timers, cron/anacron schedules; inaccessible accounts and application schedulers remain missing evidence. +- RPM package inventory; never install or alter packages. +- mounts, local/remote filesystems, free space, NFS exports and Samba shares. Distinguish remote mounts from exported storage and avoid reading protected configuration secrets. +- Configured public certificate metadata from explicit locations. Linux has no single Windows-style machine certificate store; do not claim full certificate coverage or inspect private keys. + +Common analysis must distinguish observations, inferred workloads and unknowns. Windows roles and Linux package/service workload hints are not equivalent evidence. NFS, Samba and SMB exports all require dependency review; listing a share does not prove active consumers. Account and critical infrastructure rule mappings need OS-specific coverage before production deployment. + +## Validation remaining + +Windows GUI and collection tests; Windows Server release matrix; RHEL/compatible distribution matrix; SSH authentication and host-key checks; PowerShell transport; timeout/cancellation and output bounds; incomplete permissions; production field redaction; signed packaging. No real enterprise server is contacted during development without an explicit target and authorization. diff --git a/powershell/README.md b/powershell/README.md new file mode 100644 index 0000000..56d434c --- /dev/null +++ b/powershell/README.md @@ -0,0 +1,5 @@ +# Windows collection engine (planned) + +Phase 1 does not execute PowerShell. Phase 2 will introduce Get-ServerInventory.ps1 and independent Get-ServerServices.ps1, Get-ServerNetwork.ps1, Get-ServerTasks.ps1, Get-ServerSoftware.ps1, Get-ServerStorage.ps1 and Get-ServerCertificates.ps1 scripts, plus optional AD enrichment. + +Use existing authorized PowerShell remoting from Windows. Never configure WinRM/SSH, restart services, install software, collect private keys or use Win32_Product. Inventory software via registry uninstall keys (both registry views). Return per-check failures without fabricating results. Least privilege is required; restricted inventory may produce partial results. Task/service command arguments need redaction before export. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ff022a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.6,<7 +jsonschema>=4.21,<5 diff --git a/schemas/inventory-v1.schema.json b/schemas/inventory-v1.schema.json new file mode 100644 index 0000000..8a5c720 --- /dev/null +++ b/schemas/inventory-v1.schema.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Server Buddy inventory v1", + "type": "object", + "required": [ + "schema_version", + "synthetic", + "collected_at", + "server", + "checks", + "inventory" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "1.0" + }, + "synthetic": { + "type": "boolean" + }, + "collected_at": { + "type": "string", + "format": "date-time" + }, + "server": { + "type": "object", + "required": [ + "hostname" + ], + "properties": { + "hostname": { + "type": "string" + }, + "platform": { + "enum": [ + "windows", + "linux" + ] + } + } + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "status", + "message" + ], + "properties": { + "status": { + "enum": [ + "complete", + "partial", + "failed", + "not_collected" + ] + }, + "message": { + "type": "string" + } + } + }, + "required": [ + "roles", + "services", + "network", + "tasks", + "software", + "storage", + "certificates" + ] + }, + "inventory": { + "type": "object", + "required": [ + "roles", + "services", + "network", + "tasks", + "software", + "storage", + "certificates" + ], + "properties": { + "roles": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + } + }, + "services": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "state" + ], + "properties": { + "name": { + "type": "string" + }, + "state": { + "type": "string" + } + } + } + }, + "network": { + "type": "array", + "items": { + "type": "object", + "required": [ + "state", + "local_address", + "local_port", + "remote_address", + "remote_port" + ], + "properties": { + "state": { + "type": "string" + }, + "local_address": { + "type": "string" + }, + "local_port": { + "type": "integer" + }, + "remote_address": { + "type": "string" + }, + "remote_port": { + "type": "integer" + } + } + } + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "enabled" + ], + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + } + } + }, + "software": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + } + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "name", + "path" + ], + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + }, + "certificates": { + "type": "array", + "items": { + "type": "object", + "required": [ + "subject" + ], + "properties": { + "subject": { + "type": "string" + } + } + } + } + }, + "additionalProperties": false + } + } +} diff --git a/server_buddy/__init__.py b/server_buddy/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server_buddy/__init__.py @@ -0,0 +1 @@ + diff --git a/server_buddy/analyzers/__init__.py b/server_buddy/analyzers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server_buddy/analyzers/__init__.py @@ -0,0 +1 @@ + diff --git a/server_buddy/analyzers/assessment.py b/server_buddy/analyzers/assessment.py new file mode 100644 index 0000000..466c214 --- /dev/null +++ b/server_buddy/analyzers/assessment.py @@ -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} diff --git a/server_buddy/assets/demo_inventory.json b/server_buddy/assets/demo_inventory.json new file mode 100644 index 0000000..28c804f --- /dev/null +++ b/server_buddy/assets/demo_inventory.json @@ -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" + } + ] + } +} diff --git a/server_buddy/assets/demo_linux_inventory.json b/server_buddy/assets/demo_linux_inventory.json new file mode 100644 index 0000000..27ecd5a --- /dev/null +++ b/server_buddy/assets/demo_linux_inventory.json @@ -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": [] + } +} diff --git a/server_buddy/collectors/__init__.py b/server_buddy/collectors/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server_buddy/collectors/__init__.py @@ -0,0 +1 @@ + diff --git a/server_buddy/collectors/demo.py b/server_buddy/collectors/demo.py new file mode 100644 index 0000000..63b8211 --- /dev/null +++ b/server_buddy/collectors/demo.py @@ -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()) diff --git a/server_buddy/collectors/importer.py b/server_buddy/collectors/importer.py new file mode 100644 index 0000000..cdae8bf --- /dev/null +++ b/server_buddy/collectors/importer.py @@ -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 diff --git a/server_buddy/collectors/linux.py b/server_buddy/collectors/linux.py new file mode 100644 index 0000000..d94f990 --- /dev/null +++ b/server_buddy/collectors/linux.py @@ -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() diff --git a/server_buddy/main.py b/server_buddy/main.py new file mode 100644 index 0000000..c64172c --- /dev/null +++ b/server_buddy/main.py @@ -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()) diff --git a/server_buddy/models/__init__.py b/server_buddy/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server_buddy/models/__init__.py @@ -0,0 +1 @@ + diff --git a/server_buddy/reporting/__init__.py b/server_buddy/reporting/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server_buddy/reporting/__init__.py @@ -0,0 +1 @@ + diff --git a/server_buddy/reporting/export.py b/server_buddy/reporting/export.py new file mode 100644 index 0000000..a44f64b --- /dev/null +++ b/server_buddy/reporting/export.py @@ -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 = "

Server Buddy assessment

SYNTHETIC DEMO — no real collection performed.

" if data["synthetic"] else "

Server Buddy assessment

IMPORTED INVENTORY — source assertions not independently verified.

" + if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection': + content = '

Server Buddy assessment

LOCAL LINUX INVENTORY — read-only snapshot of the execution host.

' + content += "

Heuristic requiring administrator review. This report does not authorize retirement.

" + content += f"

Risk: {escape(assessment['level'])} ({assessment['score']}/100)

" + 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"

{title}

{escape(json.dumps(value, indent=2))}
" + content += "

Recommended manual verification

" + Path(path).write_text('Server Buddy' + content + '', encoding="utf-8") diff --git a/server_buddy/ui/__init__.py b/server_buddy/ui/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server_buddy/ui/__init__.py @@ -0,0 +1 @@ + diff --git a/server_buddy/ui/window.py b/server_buddy/ui/window.py new file mode 100644 index 0000000..40ec27b --- /dev/null +++ b/server_buddy/ui/window.py @@ -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() diff --git a/tests/fixtures/synthetic_inventory.json b/tests/fixtures/synthetic_inventory.json new file mode 100644 index 0000000..b080016 --- /dev/null +++ b/tests/fixtures/synthetic_inventory.json @@ -0,0 +1,139 @@ +{ + "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" + }, + "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" + } + ] + } +} diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..8f344bb --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,55 @@ +import json +from pathlib import Path +import tempfile +import unittest +from server_buddy.core.validation import validate_hostname +from server_buddy.collectors.demo import collect_demo, DEMO_HOST +from server_buddy.analyzers.assessment import assess +from server_buddy.reporting.export import export_html, export_json + +class CoreTests(unittest.TestCase): + def test_hostname(self): + self.assertEqual(validate_hostname(' DEMO-WEB-01.example.test. '), DEMO_HOST) + for value in ['', '-host', 'a..b', 'https://host', 'host;whoami', 'host$(id)', 'a'*64]: + with self.subTest(value=value), self.assertRaises(ValueError): + validate_hostname(value) + + def test_demo_rejects_real_hosts(self): + with self.assertRaises(ValueError): + collect_demo('production.example.test') + data = collect_demo(DEMO_HOST) + data['server']['hostname'] = 'changed' + self.assertEqual(collect_demo(DEMO_HOST)['server']['hostname'], DEMO_HOST) + + def test_evidence_and_missing_activity(self): + data = collect_demo(DEMO_HOST) + result = assess(data) + self.assertTrue(all(f['evidence'] for f in result['findings'])) + self.assertTrue(any(f['classification'] == 'Confirmed observed activity' for f in result['findings'])) + data['inventory']['network'] = [] + self.assertTrue(any('does not prove' in f['evidence'] for f in assess(data)['findings'])) + self.assertTrue(any('ad:' in f['evidence'] for f in result['findings'])) + + def test_export_and_escape(self): + data = collect_demo(DEMO_HOST) + data['server']['hostname'] = '' + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / 'report.html' + export_html(report, data, assess(data)) + text = report.read_text() + self.assertNotIn('