From 967b2bd46adf2a374f304deed6147f536bf01818 Mon Sep 17 00:00:00 2001 From: oceans2alaska Date: Fri, 30 Oct 2026 10:25:55 -0700 Subject: [PATCH] Rename application to Samsara and add assessment summary, detailed tabs and local Linux inventory --- .gitignore | 4 +- App Logo/Samsara Logo.jpg | Bin 0 -> 16883 bytes README.md | 70 ++- docs/DEVELOPMENT.md | 38 +- docs/IMPLEMENTATION_PLAN.md | 38 +- docs/PLATFORMS.md | 42 +- {server_buddy => samsara}/__init__.py | 0 .../analyzers/__init__.py | 0 .../analyzers/assessment.py | 0 samsara/analyzers/summary.py | 34 ++ .../assets/demo_inventory.json | 0 .../assets/demo_linux_inventory.json | 0 samsara/assets/samsara-logo.jpg | Bin 0 -> 16883 bytes .../collectors/__init__.py | 0 {server_buddy => samsara}/collectors/demo.py | 2 +- .../collectors/importer.py | 4 +- {server_buddy => samsara}/collectors/linux.py | 15 +- samsara/collectors/linux_schedules.py | 95 ++++ samsara/main.py | 24 + {server_buddy => samsara}/models/__init__.py | 0 .../reporting/__init__.py | 0 samsara/reporting/export.py | 31 ++ {server_buddy => samsara}/ui/__init__.py | 0 samsara/ui/branding.py | 22 + samsara/ui/window.py | 465 ++++++++++++++++++ schemas/inventory-v1.schema.json | 2 +- server_buddy/main.py | 20 - server_buddy/reporting/export.py | 23 - server_buddy/ui/window.py | 318 ------------ tests/test_core.py | 10 +- tests/test_detection.py | 34 ++ tests/test_import.py | 6 +- tests/test_linux.py | 16 +- tests/test_schedules.py | 29 ++ tests/test_startup.py | 10 +- tests/test_summary.py | 35 ++ tests/test_ui.py | 139 +++--- 37 files changed, 985 insertions(+), 541 deletions(-) create mode 100644 App Logo/Samsara Logo.jpg rename {server_buddy => samsara}/__init__.py (100%) rename {server_buddy => samsara}/analyzers/__init__.py (100%) rename {server_buddy => samsara}/analyzers/assessment.py (100%) create mode 100644 samsara/analyzers/summary.py rename {server_buddy => samsara}/assets/demo_inventory.json (100%) rename {server_buddy => samsara}/assets/demo_linux_inventory.json (100%) create mode 100644 samsara/assets/samsara-logo.jpg rename {server_buddy => samsara}/collectors/__init__.py (100%) rename {server_buddy => samsara}/collectors/demo.py (90%) rename {server_buddy => samsara}/collectors/importer.py (93%) rename {server_buddy => samsara}/collectors/linux.py (92%) create mode 100644 samsara/collectors/linux_schedules.py create mode 100644 samsara/main.py rename {server_buddy => samsara}/models/__init__.py (100%) rename {server_buddy => samsara}/reporting/__init__.py (100%) create mode 100644 samsara/reporting/export.py rename {server_buddy => samsara}/ui/__init__.py (100%) create mode 100644 samsara/ui/branding.py create mode 100644 samsara/ui/window.py delete mode 100644 server_buddy/main.py delete mode 100644 server_buddy/reporting/export.py delete mode 100644 server_buddy/ui/window.py create mode 100644 tests/test_detection.py create mode 100644 tests/test_schedules.py create mode 100644 tests/test_summary.py diff --git a/.gitignore b/.gitignore index 18ac40a..8a0b01d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,8 @@ reports/ *.p12 # Local assessment artifacts (may contain infrastructure details) -server-buddy-assessment.* -server-buddy-local-*.json +*-assessment.* +*-local-*.json core core.* *.bundle diff --git a/App Logo/Samsara Logo.jpg b/App Logo/Samsara Logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..181b1b827ee8ea52d11c953f0d695e912fc3d752 GIT binary patch literal 16883 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 7a40e0f..4fea085 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,60 @@ -# Server Buddy +# Samsara -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. +Source repository: [oceans2alaska/Samsara](https://git.doorstodurin.com/oceans2alaska/Samsara). -## Linux Mint setup +Windows Server and Red Hat Enterprise Linux retirement assessment. Collect inventory, review dependencies and risks, and save evidence-based reports. Python 3.11+ and PySide6. + +## Get started ```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 +python -m samsara.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, create the environment with `py -3 -m venv .venv` and activate `.venv\Scripts\activate`. Run from the project root in a desktop session. -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. +Linux desktop compatibility includes Mint. Debian-based desktops may need `python3-venv libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` installed through their package manager. If Qt reports a missing xcb cursor library, install the package providing `libxcb-cursor.so.0`. -## Test a real assessment on your Mint PC +## Analyze -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. +1. Open **Analyze**. Leave **Assessment source → This computer** and **Operating system → Auto-detect** selected. +2. Click **Analyze**. The app identifies the local OS, runs its available read-only collector and prepares the assessment report. +3. The **Summary** page opens automatically with a retirement risk estimate, OS type/version, important software and enabled scheduled jobs/scripts. Higher scores mean more retirement concerns; the score is not retirement approval. +4. Open detailed tabs for **Networking**, **Software**, **Tasks**, **Dependencies**, **Services**, **Storage**, **Roles**, **Certificates**, **Risk findings**, **Collection checks** and **System**. Tables are searchable and sortable. +5. Click **Save HTML report**, or use **Reports** to export HTML/JSON. **History** reopens assessments from this session. -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. +Explicit profiles include Windows Server 2016, 2019, 2022 and 2025; RHEL 7 (legacy), 8, 9 and 10; and RHEL-compatible Linux. A local profile must match the detected OS. Auto-detect also allows local testing on other Linux distributions. Profile choices do not imply that every live collector is implemented or every release is validated. -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. +Choose **Saved inventory file** to analyze existing JSON; the recorded OS is displayed without probing another computer. **Windows Server sample** and **RHEL sample** provide clearly labeled synthetic examples. No synthetic results are loaded into history automatically. -### Current local coverage and limits +## Current availability -- 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. +Local Linux collection and offline reporting are implemented. Live Windows and remote collection are not yet available; the UI reports that limitation explicitly. Windows desktops can use saved inventories and samples, but Windows desktop execution still requires platform validation. No server is contacted automatically. -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. +The Linux collector observes OS/kernel/boot information, addresses, loaded systemd services and timers (including available calendar/next-run metadata), readable system cron entries, TCP snapshots, RPM or dpkg software and mounted filesystems. Missing commands or restricted permissions produce visible failed checks. Service accounts, user crontabs/timers, anacron/periodic-script configuration, certificate locations, share exports, disk capacity/free space and directory integration remain incomplete or uncollected. Results reflect the execution process's namespace and permissions. Remote mounts are not accessed. -## Headless local Linux inventory +Collection is read-only, requires no sudo and does not restart services, configure remoting, install software or read private keys, secret-bearing command lines or environment variables. Cancel stops the active collector and retains the previous assessment. Commands have time and output limits. -The local collector can also produce JSON without PySide6 or jsonschema (Python standard library only): +The summary highlights name-matched server software and running application services; this is not a complete application classifier. Scheduled entries show configured work, not confirmed execution, and cron timezones are unverified. Full inventory and collection limitations remain available in the tabs and exported report. + +Risk is a preliminary heuristic requiring administrator review. Missing connections do not prove non-use, and the tool never declares a server safe for retirement. Review exported hostnames, paths and addresses before sharing. + +## Headless Linux inventory + +The standard-library-only collector can export local inventory without Qt: ```bash -python3 -m server_buddy.collectors.linux --output /tmp/my-local-inventory.json +python3 -m samsara.collectors.linux --output /tmp/server-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. +Use a new filename; existing files are not overwritten. Open the result using **Saved inventory file** on either desktop. Imported check assertions are not independently verified. Import validates the v1 schema, limits input to 10 MiB, removes external diagnostic details and service/task command fields, and recalculates risk. -## 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 +## Development ```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. +Qt tests run offscreen. See [platform support](docs/PLATFORMS.md), [architecture and plan](docs/IMPLEMENTATION_PLAN.md) and [developer guide](docs/DEVELOPMENT.md). Run from source; Windows packaging and production collector validation remain pending. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7816185..791a1a2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,29 +1,37 @@ -# Development guide +# Developer 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. +Use Python 3.11+ and requirements.txt. Run `python -m samsara.main` from the repository root. The desktop requires a Qt-capable graphical session; tests run offscreen. -## Phase 1 behavior +## UI and orchestration -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. +Analyze is the default page. Source and OS selection are visible together; Auto-detect is the default. The OS selector is disabled for saved inventories and fixed synthetic samples because their OS is recorded in the input. Windows and RHEL version profiles are explicit user intent, not promises of live collector availability. -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. +`CollectionJob` runs detection and collection outside the GUI thread, then delivers stage, result or failure signals. OS mismatches and unsupported collectors preserve the previous result. Cancellation uses QThread interruption and the Linux runner's polling callback. All source/profile controls are locked while collecting. Closing an active worker is deferred to avoid destroying a running thread. -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. +Results are initially empty. Successful assessments create session history records; choosing one restores its inventory and derived assessment. Reports always use the displayed assessment. Successful analysis opens the dedicated Summary page. Its first tab shows retirement risk, OS/version, a capped list of workload software hints and enabled scheduled work. Comprehensive tabs lazily load searchable inventory and evidence tables. The same compact summary is included in HTML reports; all inventory remains exported. Scrollable pages and fixed-size form cards preserve legibility at smaller window sizes. -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. +## OS detection -## Verification in this development environment +`core/detection.py` reads local platform metadata and `/etc/os-release`, recognizes RHEL-related families and validates explicit major-version profiles. It does not resolve names or contact remote systems. Other Linux distributions may use Auto-detect. Windows desktop build numbers are not treated as Windows Server edition evidence. Saved inventories use their metadata and skip local OS detection. -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. +## Linux backend -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. +`collectors/linux.py` executes fixed arguments from system binary directories with a restricted environment, no shell or user hostname, suppressed command diagnostics and no sudo. Commands are polled for cancellation, an eight-second limit and 10 MiB output bound, then killed/reaped on failure. Overall command collection is limited to approximately 60 seconds. Reads are limited to local OS/proc metadata; remote mounts are not traversed. The headless CLI uses only the standard library and creates new output files exclusively. -## Follow-up verification +Report source is included in locally collected server metadata. Import removes that field and treats source assertions as unverified. Inventory import validates schemas/inventory-v1.schema.json, caps size, strips diagnostic strings and command fields, and recalculates assessment results. Tighten field definitions alongside future production collector contracts. -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. +## Analysis -## Local Linux collector +Each finding has classification, supporting evidence, severity and points. Score is capped at 100: 0 informational, 1–29 low, 30–59 medium, 60–89 high, 90–100 critical. This is demonstration heuristic logic, not a validated production risk model. Known Linux application service names receive potential-dependency findings; other running services remain unknown rather than all being classified as application workloads. Incomplete checks and observation limits always require review. -`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 -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. +Run `python -m unittest discover -s tests -v`. Coverage includes profiles/detection, synthetic isolation, real-source labels, RPM/dpkg parsing, IPv4/IPv6, missing commands, cancellation/timeouts, no automatic assessments, history restoration, imports, UI selection and HTML escaping/export. Offscreen snapshots can verify geometry at standard and smaller resolutions; they do not replace native desktop testing. + +A local Linux smoke collection has passed schema validation and HTML/JSON generation in the restricted development environment. Systemd/interface restrictions were recorded as failed checks. Real Windows and RHEL release validation, remote transports and packaging are still pending. Never replace missing checks with samples. + +Summary presentation is derived by analyzers/summary.py without altering assessment scores. Higher numbers mean more retirement concerns. OS version uses explicit os_version or an OS-name version hint, never the kernel/build as a fallback. Software name rules omit obvious library/development/documentation packages; a running service without a matching package can still be highlighted. Unknown schedules and missing checks remain explicit. Linux scheduler metadata includes system timer calendars/next-run data and bounded readable /etc/crontab and /etc/cron.d entries. Cron command arguments, inline code and environment assignments never enter the collected data; safe script/executable names identify work. User crontabs, anacron, periodic-script contents and execution history remain outside coverage. + +## Application branding + +The supplied Samsara JPEG is bundled unchanged as `samsara/assets/samsara-logo.jpg`. `ui/branding.py` provides the shared Qt application/window icon and Windows taskbar application identity. The sidebar uses the same asset. Future packaged builds must include this asset; the original `App Logo` folder is not required at runtime. Icon loading was checked at 16–256 pixel sizes and the sidebar preview was reviewed offscreen. Native desktop/taskbar appearance still needs platform verification. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 28fa36c..08d036b 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1,24 +1,32 @@ -# Implementation plan +# Architecture and implementation plan -Repository inspection: empty project directory with reserved environment directories; no existing application or repository instructions. +Samsara is a Windows Server and enterprise Linux retirement assessment tool. Desktop OS and assessed OS are independent. -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. +## Project structure -## Structure and boundaries +- `samsara/ui`: Qt interface and background collection worker. +- `samsara/core`: hostname validation, OS detection and profile matching. +- `samsara/collectors`: offline fixtures, schema-validated JSON intake and local Linux collector. +- `samsara/analyzers`: evidence classification and transparent heuristic scoring. +- `samsara/reporting`: local HTML/JSON export. +- `samsara/models`: reserved for typed collection models. +- `samsara/assets`: labeled synthetic fixtures. +- `schemas`: versioned collection JSON contract. +- `powershell`: Windows collection requirements; scripts pending. +- `tests`: core, parsing, detection, worker, UI and export tests. -`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. +## Implemented -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. +The simplified UI starts on Analyze with This computer and Auto-detect selected. History, Reports and Settings are secondary pages. No fabricated assessment is inserted at startup. Local collection detects the OS and uses the Linux backend where available. Version mismatches and unsupported Windows collection fail explicitly. Saved inventories and synthetic samples have separate source choices. A dedicated Summary destination opens after collection, with retirement risk, OS/version, important software and scheduled work; comprehensive Networking, Software, Tasks, Dependencies and other tabs expose evidence. Reports can be saved immediately after an assessment; session history reopens previous results. -## Scope expansion: Linux targets and desktop startup +Linux collection provides read-only OS, service, timer calendar, system cron, TCP, package and mount observations. Missing evidence is represented by check status, never by synthetic substitution. Commands have cancellation, time/output limits and cleanup. See README for exact omissions. -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. +## Remaining phases -## Local Linux testing implementation +1. Independent Windows inventory scripts for system/roles, services, TCP/processes, tasks, registry software, storage/shares and public certificate metadata. Do not use Win32_Product. Optional AD enrichment must not become a prerequisite. +2. Authorized Windows execution-host remoting and Linux SSH transport, explicit targets/authentication, host verification, bounded output, timeouts and cancellation. No infrastructure configuration. +3. Expand Linux inventory for user schedulers and remaining cron/anacron coverage, identities, share exports, disk usage and application-specific public certificates with least-privilege access. +4. Validate OS-specific application/critical infrastructure rules, observed versus inferred relationships and historical/manual verification workflows. No automatic retirement approval. +5. Production schemas, redaction, Windows/RHEL platform testing, packaging and signed releases. -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. +The original empty repository had no existing application or project instructions. All current collection is local or offline; no enterprise targets are contacted in development. diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index c5d06e7..e713b76 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -1,34 +1,20 @@ -# Platform support and collection plan +# Platform support -## Current functionality +Samsara targets retirement assessment of Windows Server 2016, 2019, 2022 and 2025, RHEL 7 (legacy)/8/9/10, and compatible enterprise Linux distributions. OS profiles express the intended assessment target, not a tested support guarantee for live collection. -| 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 | +| Capability | Current availability | +| --- | --- | +| Local Linux collection | Implemented; RPM and dpkg inventory paths | +| OS auto-detection | Local OS identification; Linux distribution family and major version checks | +| Saved inventory analysis | Windows/Linux v1 JSON, with OS supplied by inventory metadata | +| Windows live collection | Not implemented | +| Remote SSH/PowerShell collection | Not implemented | +| Desktop | Python 3.11+ with Qt on Linux and Windows; Windows execution validation pending | -**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. +Auto-detection reads local OS metadata without remote name resolution or probing. Explicit RHEL selections check family and major version. Other Linux distributions can use Auto-detect for local testing. Windows identification does not infer a Server edition from a desktop kernel number. Unsupported live collectors return an explicit error while keeping prior results. -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. +The local collector uses fixed read-only system commands and records partial or failed checks for unsupported commands, permissions and missing evidence. Test each RHEL/distribution release before relying on production output. Containers may restrict systemd, interfaces, processes and mount namespaces. -## Target collection support +Future Windows collection must use existing authorized remoting from a Windows execution host, compatible with Windows PowerShell 5.1. Windows administration modules must not be run natively on Linux. Future Linux remote collection must use existing authorized SSH with host-key verification. Neither transport should enable services, install an agent or escalate privileges automatically. -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. +Required production validation includes Windows desktop/server release testing, RHEL/distribution testing, incomplete permissions, process lifecycle behavior, schema contracts, report redaction, packaging and signing. Linux inventory coverage and current omissions are documented in README.md. diff --git a/server_buddy/__init__.py b/samsara/__init__.py similarity index 100% rename from server_buddy/__init__.py rename to samsara/__init__.py diff --git a/server_buddy/analyzers/__init__.py b/samsara/analyzers/__init__.py similarity index 100% rename from server_buddy/analyzers/__init__.py rename to samsara/analyzers/__init__.py diff --git a/server_buddy/analyzers/assessment.py b/samsara/analyzers/assessment.py similarity index 100% rename from server_buddy/analyzers/assessment.py rename to samsara/analyzers/assessment.py diff --git a/samsara/analyzers/summary.py b/samsara/analyzers/summary.py new file mode 100644 index 0000000..692cadc --- /dev/null +++ b/samsara/analyzers/summary.py @@ -0,0 +1,34 @@ +"""Compact, evidence-based presentation of the full assessment.""" +import re + +# Name hints for server workloads, not a comprehensive application classifier. +WORKLOAD = re.compile(r'(?:nginx|apache|httpd|iis|w3svc|sql|postgres|mariadb|mysql|redis|mongo|oracle|tomcat|haproxy|docker|containerd|podman|samba|smbd|nfs|bind|named|rabbitmq|kafka|jenkins|backup|reporting)', re.I) + + +def summarize(data, assessment): + server, inventory = data['server'], data['inventory'] + os_name = str(server.get('operating_system') or 'Not collected') + version = server.get('os_version') + if not version: + match = re.search(r'\b(?:20\d{2}|\d+(?:\.\d+)+)\b', os_name) + version = match[0] if match else 'Not collected' + version = str(version) + important = [] + for row in inventory.get('software', []): + if WORKLOAD.search(row['name']) and not row['name'].lower().startswith('lib') and not row['name'].lower().endswith(('-dev', '-devel', '-doc')): + important.append({'name': row['name'], 'version': row.get('version', 'Not collected'), + 'evidence': 'Installed software name matches a server-workload hint; use unverified'}) + names = {row['name'].lower().split(':')[0] for row in important} + for row in inventory.get('services', []): + if row.get('state') == 'Running' and WORKLOAD.search(row['name']) and row['name'].lower().removesuffix('.service') not in names: + important.append({'name': row['name'], 'version': 'Not collected', 'evidence': 'Running service; application ownership requires review'}) + jobs = [] + for row in inventory.get('tasks', []): + if row.get('enabled') or row.get('state') == 'Running': + jobs.append({'name': row['name'], 'schedule': row.get('triggers', 'Schedule not collected'), + 'status': row.get('state', 'Enabled schedule; execution unverified'), + 'account': row.get('account', 'Not collected')}) + return {'os_type': os_name, 'version': version, + 'risk_score': assessment['score'], 'risk_level': assessment['level'], + 'software': important, 'jobs': jobs, + 'incomplete': sum(check['status'] != 'complete' for check in data['checks'].values())} diff --git a/server_buddy/assets/demo_inventory.json b/samsara/assets/demo_inventory.json similarity index 100% rename from server_buddy/assets/demo_inventory.json rename to samsara/assets/demo_inventory.json diff --git a/server_buddy/assets/demo_linux_inventory.json b/samsara/assets/demo_linux_inventory.json similarity index 100% rename from server_buddy/assets/demo_linux_inventory.json rename to samsara/assets/demo_linux_inventory.json diff --git a/samsara/assets/samsara-logo.jpg b/samsara/assets/samsara-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..181b1b827ee8ea52d11c953f0d695e912fc3d752 GIT binary patch literal 16883 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 literal 0 HcmV?d00001 diff --git a/server_buddy/collectors/__init__.py b/samsara/collectors/__init__.py similarity index 100% rename from server_buddy/collectors/__init__.py rename to samsara/collectors/__init__.py diff --git a/server_buddy/collectors/demo.py b/samsara/collectors/demo.py similarity index 90% rename from server_buddy/collectors/demo.py rename to samsara/collectors/demo.py index 63b8211..a2ae269 100644 --- a/server_buddy/collectors/demo.py +++ b/samsara/collectors/demo.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from server_buddy.core.validation import validate_hostname +from samsara.core.validation import validate_hostname DEMO_HOST = "demo-web-01.example.test" LINUX_DEMO_HOST = "demo-rhel-01.example.test" diff --git a/server_buddy/collectors/importer.py b/samsara/collectors/importer.py similarity index 93% rename from server_buddy/collectors/importer.py rename to samsara/collectors/importer.py index cdae8bf..3cb4a59 100644 --- a/server_buddy/collectors/importer.py +++ b/samsara/collectors/importer.py @@ -12,12 +12,12 @@ 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'}, + 'tasks': {'name', 'path', 'enabled', 'account', 'triggers', 'state', 'associated_unit'}, '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'} +SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_build', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'} def load_inventory(path): diff --git a/server_buddy/collectors/linux.py b/samsara/collectors/linux.py similarity index 92% rename from server_buddy/collectors/linux.py rename to samsara/collectors/linux.py index d94f990..b73c6b9 100644 --- a/server_buddy/collectors/linux.py +++ b/samsara/collectors/linux.py @@ -10,6 +10,7 @@ import subprocess import tempfile import time from datetime import datetime, timezone +from samsara.collectors.linux_schedules import read_system_cron, enrich_timers class CollectionCancelled(Exception): @@ -98,7 +99,7 @@ def parse_connections(text): 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.') + raise ValueError('Local Linux collection requires a Linux execution host.') started = time.monotonic() data = {'schema_version': '1.0', 'synthetic': False, 'collected_at': datetime.now(timezone.utc).isoformat(), @@ -134,6 +135,7 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run key, value = line.split('=', 1) release[key] = ' '.join(shlex.split(value)) data['server']['operating_system'] = release.get('PRETTY_NAME', 'Linux (distribution unknown)') + data['server']['os_version'] = release.get('VERSION_ID', 'Not collected') 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.') @@ -147,7 +149,16 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run 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 timers(): + units('timer') + enrich_timers(data['inventory']['tasks'], command) + check('tasks', timers, 'Loaded system timers and available calendar/next-run metadata observed; unloaded/user timers and application schedulers require review.') + def cron(): + rows, incomplete = read_system_cron(cancelled) + data['inventory']['tasks'].extend(rows) + if incomplete: + raise RuntimeError('Some system cron files inaccessible') + check('cron', cron, 'Readable /etc/crontab and /etc/cron.d entries observed. Arguments omitted. User crontabs, anacron rules and periodic-script contents not inspected; timezone and actual execution unverified.') 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.') diff --git a/samsara/collectors/linux_schedules.py b/samsara/collectors/linux_schedules.py new file mode 100644 index 0000000..0ffc785 --- /dev/null +++ b/samsara/collectors/linux_schedules.py @@ -0,0 +1,95 @@ +"""Read scheduler metadata only; never run jobs or report their arguments.""" +from pathlib import Path +import re +import shlex + +SAFE_PATH = re.compile(r'^[A-Za-z0-9_./+-]+$') + + +def cron_rows(text, source, system_file=False): + rows = [] + for number, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if not line or line.startswith('#') or re.match(r'^[A-Za-z_][A-Za-z0-9_]*\s*=', line): + continue + parts = line.split(None, 1 if line.startswith('@') else 5) + count = 1 if line.startswith('@') else 5 + if count == 1 and parts[0] not in {'@reboot', '@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'}: + continue + if len(parts) <= count: + continue + schedule, command = ' '.join(parts[:count]), parts[count] + account = 'Current user' + if system_file: + user_command = command.split(None, 1) + if len(user_command) != 2: + continue + account, command = user_command + # A safe executable/script filename is enough to identify the workload. + # Arguments, embedded scripts and environment assignments never leave here. + try: + tokens = shlex.split(command) + except ValueError: + tokens = [] + executable = tokens[0] if tokens and SAFE_PATH.fullmatch(tokens[0]) else '[command omitted]' + script = executable + if Path(executable).name.startswith(('python', 'perl', 'ruby', 'bash', 'sh')) and len(tokens) > 1: + if SAFE_PATH.fullmatch(tokens[1]) and not tokens[1].startswith('-'): + script = tokens[1] + rows.append({'name': f'{Path(script).name} ({source}:{number})', 'path': source, + 'enabled': True, 'account': account, 'executable': executable, + 'triggers': schedule + ' (cron; timezone not resolved)', + 'state': 'Configured schedule; execution unverified'}) + return rows + + +def read_system_cron(cancelled=lambda: False): + paths = [Path('/etc/crontab')] + directory = Path('/etc/cron.d') + if directory.exists(): + paths.extend(sorted(directory.iterdir())) + rows, incomplete = [], False + incomplete = len(paths) > 256 + for path in paths[:256]: + if cancelled(): + from samsara.collectors.linux import CollectionCancelled + raise CollectionCancelled() + if not path.exists(): + continue + # Avoid following symlinks or special files. Bound every file read. + if path.is_symlink() or not path.is_file(): + incomplete = True + continue + if path.name.startswith('.') or path.name.endswith(('~', '.bak', '.dpkg-old', '.rpmnew', '.rpmsave')): + continue + try: + with path.open('r', encoding='utf-8', errors='replace') as source: + text = source.read(65537) + if len(text) > 65536: + incomplete = True + continue + rows.extend(cron_rows(text, str(path), system_file=True)) + except OSError: + incomplete = True + return rows, incomplete + + +def enrich_timers(rows, command): + names = [row['name'] for row in rows if re.fullmatch(r'[A-Za-z0-9_.@:-]+\.timer', row['name'])] + if not names: + return + output = command(['systemctl', 'show', '--property=Id', '--property=TimersCalendar', + '--property=NextElapseUSecRealtime', '--property=Triggers', '--no-pager', '--', *names[:200]]) + details = {} + for block in output.strip().split('\n\n'): + properties = dict(line.split('=', 1) for line in block.splitlines() if '=' in line) + if 'Id' in properties: + details[properties['Id']] = properties + for row in rows: + properties = details.get(row['name'], {}) + schedule = properties.get('TimersCalendar', '') + next_run = properties.get('NextElapseUSecRealtime', '') + if schedule or next_run: + row['triggers'] = '; '.join(filter(None, [schedule, 'Next: ' + next_run if next_run else ''])) + if properties.get('Triggers'): + row['associated_unit'] = properties['Triggers'] diff --git a/samsara/main.py b/samsara/main.py new file mode 100644 index 0000000..9c1941e --- /dev/null +++ b/samsara/main.py @@ -0,0 +1,24 @@ +import sys +from samsara.core.startup import desktop_error + + +def main(): + error = desktop_error() + if error: + print(error, file=sys.stderr) + return 1 + from samsara.ui.branding import configure_taskbar_identity, application_icon + configure_taskbar_identity() + from PySide6.QtWidgets import QApplication + from samsara.ui.window import MainWindow + app = QApplication(sys.argv) + app.setApplicationName('Samsara') + app.setDesktopFileName('samsara') + app.setWindowIcon(application_icon()) + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/server_buddy/models/__init__.py b/samsara/models/__init__.py similarity index 100% rename from server_buddy/models/__init__.py rename to samsara/models/__init__.py diff --git a/server_buddy/reporting/__init__.py b/samsara/reporting/__init__.py similarity index 100% rename from server_buddy/reporting/__init__.py rename to samsara/reporting/__init__.py diff --git a/samsara/reporting/export.py b/samsara/reporting/export.py new file mode 100644 index 0000000..93a7c41 --- /dev/null +++ b/samsara/reporting/export.py @@ -0,0 +1,31 @@ +import html +import json +from pathlib import Path +from samsara.analyzers.summary import summarize + +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 = "

Samsara assessment

SYNTHETIC DEMO — no real collection performed.

" if data["synthetic"] else "

Samsara assessment

IMPORTED INVENTORY — source assertions not independently verified.

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

Samsara assessment

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

' + content += "

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

" + summary = summarize(data, assessment) + content += f"

Summary

Retirement risk estimate: {escape(summary['risk_level'])} ({summary['risk_score']}/100). Higher scores mean more retirement concerns.

" + content += f"

OS type: {escape(summary['os_type'])}
Version: {escape(summary['version'])}
Incomplete or missing checks: {summary['incomplete']}

" + content += '

Important software

Workload name hints and application services; use unverified.

    ' + content += ''.join(f"
  • {escape(row['name'])} — {escape(row['version'])}
  • " for row in summary['software']) or '
  • No workload name matches observed; review full inventory and missing checks.
  • ' + content += '

Scheduled jobs and scripts

Configured schedules do not confirm execution.

    ' + content += ''.join(f"
  • {escape(row['name'])} — {escape(row['schedule'])}
  • " for row in summary['jobs']) or '
  • No enabled schedules observed; scheduler coverage may be incomplete.
  • ' + content += '
' + 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

    " + "".join(f"
  • {escape(s)}
  • " for s in MANUAL_STEPS) + "
" + Path(path).write_text('Samsara' + content + '', encoding="utf-8") diff --git a/server_buddy/ui/__init__.py b/samsara/ui/__init__.py similarity index 100% rename from server_buddy/ui/__init__.py rename to samsara/ui/__init__.py diff --git a/samsara/ui/branding.py b/samsara/ui/branding.py new file mode 100644 index 0000000..91da957 --- /dev/null +++ b/samsara/ui/branding.py @@ -0,0 +1,22 @@ +"""Application identity and bundled logo shared by the desktop entry points.""" +from pathlib import Path +import sys + +APP_ID = 'com.doorstodurin.Samsara' +LOGO_PATH = Path(__file__).resolve().parents[1] / 'assets' / 'samsara-logo.jpg' + + +def configure_taskbar_identity(): + """Set the Windows process identity before creating any Qt windows.""" + if sys.platform == 'win32': + import ctypes + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID) + except (AttributeError, OSError): + # A shell integration failure must not prevent assessments. + pass + + +def application_icon(): + from PySide6.QtGui import QIcon + return QIcon(str(LOGO_PATH)) diff --git a/samsara/ui/window.py b/samsara/ui/window.py new file mode 100644 index 0000000..08983a3 --- /dev/null +++ b/samsara/ui/window.py @@ -0,0 +1,465 @@ +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, QTableView, + QFileDialog, QMessageBox, QComboBox, QHeaderView, QFrame, QProgressBar, QScrollArea, QSizePolicy, QLayout, QTabWidget) + +from samsara.ui.branding import application_icon +from samsara.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST +from samsara.collectors.importer import load_inventory +from samsara.collectors.linux import collect_local_linux, CollectionCancelled +from samsara.core.detection import detect_local_os, check_profile, PROFILES +from samsara.analyzers.assessment import assess +from samsara.analyzers.summary import summarize +from samsara.reporting.export import export_html, export_json + + +class CollectionJob(QThread): + stage = Signal(str) + result = Signal(dict) + failed = Signal(str) + + def __init__(self, source, profile='auto', inventory_path='', parent=None): + super().__init__(parent) + self.source, self.profile, self.inventory_path = source, profile, inventory_path + + def run(self): + try: + if self.source == 'local': + self.stage.emit('Detecting operating system…') + detected = detect_local_os() + if detected['platform'] != 'linux': + raise ValueError('Windows live collection is not available yet. Open a saved inventory or a sample assessment.') + check_profile(detected, self.profile) + self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…') + data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested) + elif self.source == 'file': + self.stage.emit('Reading saved inventory…') + data = load_inventory(self.inventory_path) + else: + self.stage.emit('Loading sample assessment…') + data = collect_demo(DEMO_HOST if self.source == 'windows_demo' else LINUX_DEMO_HOST) + if self.isInterruptionRequested(): + raise CollectionCancelled() + self.stage.emit('Preparing assessment report…') + self.result.emit(data) + except CollectionCancelled: + self.failed.emit('Cancelled. Your previous assessment has been kept.') + except ValueError as exc: + self.failed.emit(str(exc)) + except Exception: + self.failed.emit('Unable to complete assessment. Check file access and collection availability; previous results have been kept.') + + +class SearchTable(QWidget): + def __init__(self, rows): + super().__init__() + layout = QVBoxLayout(self) + self.setMinimumHeight(230) + 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('Samsara') + self.setWindowIcon(application_icon()) + self.resize(1180, 820) + self.setMinimumSize(800, 620) + self.job = None + self.data = None + self.assessment = None + self.history = [] + self.records = [] + self.setStyleSheet(''' + QWidget { background:#131b27; color:#e5ecf5; font-size:14px; } + QLabel#title { font-size:27px; font-weight:600; } + QLabel#muted { color:#a4b3c7; } + QLabel#summary { font-size:17px; font-weight:600; } + QFrame#card { background:#1b2839; border:1px solid #304158; border-radius:8px; } + QFrame#card QLabel { background:transparent; } + QPushButton { background:#283b52; border:1px solid #40546c; border-radius:5px; padding:9px 16px; } + QPushButton#primary { background:#287ac0; border:0; font-weight:600; } + QPushButton:hover { background:#346087; } QPushButton:disabled { color:#7e8c9e; background:#253143; } + QLineEdit,QComboBox { background:#1e2c3e; border:1px solid #465b75; border-radius:5px; padding:9px; min-height:20px; } + QListWidget { background:#172231; border:0; outline:0; } + QListWidget::item { padding:14px 12px; margin:3px; border-radius:5px; } + QListWidget::item:selected { background:#254b70; } + QTabBar::tab { background:#1b2839; padding:10px 12px; border-bottom:2px solid transparent; } + QTabBar::tab:selected { border-bottom:2px solid #469fe4; background:#24384f; } + QHeaderView::section { background:#273a51; padding:9px; border:0; } + QTableView { alternate-background-color:#1b293c; gridline-color:#304057; border:1px solid #304057; } + QProgressBar { max-height:4px; border:0; } QProgressBar::chunk { background:#398ed1; } + ''') + central = QWidget() + self.setCentralWidget(central) + outer = QHBoxLayout(central) + outer.setContentsMargins(0, 0, 0, 0) + sidebar = QVBoxLayout() + sidebar.setContentsMargins(12, 22, 12, 16) + brand_row = QHBoxLayout() + brand_row.setSpacing(8) + self.logo = QLabel() + self.logo.setFixedSize(40, 40) + self.logo.setPixmap(application_icon().pixmap(40, 40)) + self.logo.setToolTip('Samsara') + self.logo.setAccessibleName('Samsara logo') + brand_row.addWidget(self.logo) + brand = QLabel('Samsara') + brand.setObjectName('summary') + brand_row.addWidget(brand) + sidebar.addLayout(brand_row) + sidebar.addWidget(self.label('Retirement assessments', muted=True)) + self.nav = QListWidget() + self.nav.addItems(['Summary', 'Analyze', 'History', 'Reports', 'Settings']) + self.nav.setFixedWidth(170) + sidebar.addWidget(self.nav) + outer.addLayout(sidebar) + self.pages = QStackedWidget() + outer.addWidget(self.pages, 1) + self.build_pages() + self.nav.currentRowChanged.connect(lambda index: self.pages.setCurrentIndex({0: 1, 1: 0, 2: 2, 3: 3, 4: 4}[index])) + self.nav.setCurrentRow(1) + self.source_changed() + + def label(self, text, muted=False): + label = QLabel(text) + label.setWordWrap(True) + label.setTextFormat(Qt.TextFormat.PlainText) + if muted: + label.setObjectName('muted') + return label + + def page(self, title, subtitle): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(24, 22, 24, 20) + layout.setSpacing(14) + heading = self.label(title) + heading.setObjectName('title') + layout.addWidget(heading) + layout.addWidget(self.label(subtitle, muted=True)) + layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setWidget(widget) + self.pages.addWidget(scroll) + return layout + + def build_pages(self): + analyze = self.page('Analyze', 'Choose a source, then analyze its retirement dependencies and risks.') + card = QFrame() + card.setObjectName('card') + card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + form = QVBoxLayout(card) + form.setContentsMargins(18, 16, 18, 16) + form.setSpacing(10) + row = QHBoxLayout() + source_col = QVBoxLayout() + source_col.addWidget(self.label('Assessment source')) + self.source = QComboBox() + for label, value in [('This computer', 'local'), ('Saved inventory file', 'file'), + ('Windows Server sample', 'windows_demo'), ('RHEL sample', 'linux_demo')]: + self.source.addItem(label, value) + self.source.currentIndexChanged.connect(self.source_changed) + source_col.addWidget(self.source) + row.addLayout(source_col, 1) + os_col = QVBoxLayout() + os_col.addWidget(self.label('Operating system')) + self.target = QComboBox() + for label, value in PROFILES: + self.target.addItem(label, value) + os_col.addWidget(self.target) + row.addLayout(os_col, 1) + form.addLayout(row) + self.file_row = QWidget() + file_layout = QHBoxLayout(self.file_row) + file_layout.setContentsMargins(0, 0, 0, 0) + self.inventory_path = QLineEdit(placeholderText='Choose an inventory JSON file…') + file_layout.addWidget(self.inventory_path, 1) + self.browse = QPushButton('Browse…') + self.browse.clicked.connect(self.browse_inventory) + file_layout.addWidget(self.browse) + form.addWidget(self.file_row) + self.source_hint = self.label('', muted=True) + form.addWidget(self.source_hint) + actions = QHBoxLayout() + self.analyze_button = QPushButton('Analyze') + self.analyze_button.setObjectName('primary') + self.analyze_button.clicked.connect(self.start_collection) + actions.addWidget(self.analyze_button) + self.cancel_button = QPushButton('Cancel') + self.cancel_button.clicked.connect(self.cancel_collection) + self.cancel_button.setVisible(False) + actions.addWidget(self.cancel_button) + actions.addStretch() + form.addLayout(actions) + analyze.addWidget(card) + self.status = self.label('Ready to analyze.', muted=True) + analyze.addWidget(self.status) + self.progress = QProgressBar() + self.progress.setRange(0, 0) + self.progress.hide() + analyze.addWidget(self.progress) + analyze.addStretch() + summary_page = self.page('Assessment', 'Start with the summary, then explore the evidence in each tab.') + self.result_title = self.label('No assessment yet') + self.result_title.setObjectName('summary') + self.banner = self.label('Choose Analyze to collect or open inventory.', muted=True) + summary_page.addWidget(self.result_title) + summary_page.addWidget(self.banner) + self.result_tabs = QTabWidget() + self.result_tabs.setUsesScrollButtons(True) + self.result_tabs.setDocumentMode(True) + summary_page.addWidget(self.result_tabs, 1) + self.summary_widget = QWidget() + summary_layout = QVBoxLayout(self.summary_widget) + summary_layout.setContentsMargins(12, 16, 12, 16) + summary_layout.setSpacing(16) + score_card = QFrame() + score_card.setObjectName('card') + score_card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + score_layout = QVBoxLayout(score_card) + self.retirement_score = self.label('Retirement risk estimate: —') + self.retirement_score.setObjectName('summary') + score_layout.addWidget(self.retirement_score) + score_layout.addWidget(self.label('Higher scores mean more retirement concerns. Heuristic only; administrator review is required.', muted=True)) + self.coverage_note = self.label('', muted=True) + score_layout.addWidget(self.coverage_note) + summary_layout.addWidget(score_card) + self.os_type = self.label('OS type: —') + self.os_version = self.label('Version: —') + summary_layout.addWidget(self.os_type) + summary_layout.addWidget(self.os_version) + software_heading = self.label('Important software') + software_heading.setObjectName('summary') + summary_layout.addWidget(software_heading) + self.important_software = self.label('Run an assessment to see detected server software.') + summary_layout.addWidget(self.important_software) + summary_layout.addWidget(self.label('Workload name matches and application services; see Software for the full inventory.', muted=True)) + jobs_heading = self.label('Scheduled jobs & scripts') + jobs_heading.setObjectName('summary') + summary_layout.addWidget(jobs_heading) + self.scheduled_jobs = self.label('Run an assessment to see enabled schedules.') + summary_layout.addWidget(self.scheduled_jobs) + summary_layout.addStretch() + self.quick_save = QPushButton('Save HTML report') + self.quick_save.setEnabled(False) + self.quick_save.clicked.connect(lambda: self.save_report('html')) + summary_layout.addWidget(self.quick_save) + self.result_tabs.addTab(self.summary_widget, 'Summary') + self.detail_areas = {} + for title, key in [('Networking', 'network'), ('Software', 'software'), ('Tasks', 'tasks'), + ('Dependencies', 'dependencies'), ('Services', 'services'), ('Storage & shares', 'storage'), + ('Roles', 'roles'), ('Certificates', 'certificates'), ('Risk findings', 'risk'), + ('Collection checks', 'checks'), ('System', 'overview')]: + tab = QWidget() + area = QVBoxLayout(tab) + area.addWidget(self.label('No assessment loaded.', muted=True)) + self.detail_areas[key] = area + self.result_tabs.addTab(tab, title) + self.result_tabs.currentChanged.connect(self.show_details) + history = self.page('History', 'Assessments from this session. Select a record to open it.') + self.history_list = QListWidget() + self.history_list.itemActivated.connect(self.open_history) + self.history_list.itemClicked.connect(self.open_history) + history.addWidget(self.history_list) + self.history_empty = self.label('No assessments yet.', muted=True) + history.addWidget(self.history_empty) + reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.') + self.report_label = self.label('Analyze a computer or open an inventory first.', muted=True) + reports.addWidget(self.report_label) + self.report_buttons = [] + for label, kind in [('Save HTML report', 'html'), ('Export JSON', 'json')]: + button = QPushButton(label) + button.clicked.connect(lambda checked=False, fmt=kind: self.save_report(fmt)) + button.setEnabled(False) + self.report_buttons.append(button) + reports.addWidget(button) + reports.addStretch() + settings = self.page('Settings', 'Collection availability and assessment behavior.') + settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions')) + settings.addWidget(self.label('Local Linux collection is available. Live Windows and remote collection are not available yet. Saved inventories and samples work on either desktop.', muted=True)) + settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True)) + settings.addStretch() + + def source_changed(self): + source = self.source.currentData() + self.file_row.setVisible(source == 'file') + self.target.setEnabled(source == 'local' and self.job is None) + if source != 'local': + self.target.setCurrentIndex(0) + hints = {'local': 'Auto-detect identifies this computer’s OS and runs its available collector. No remote connection is made.', + 'file': 'The operating system is read from the saved inventory. No connection is made.', + 'windows_demo': 'Synthetic Windows Server 2022 example. No server is contacted.', + 'linux_demo': 'Synthetic RHEL 9 example. No server is contacted.'} + self.source_hint.setText(hints[source]) + + def browse_inventory(self): + path, _ = QFileDialog.getOpenFileName(self, 'Open inventory', '', 'Inventory JSON (*.json)') + if path: + self.inventory_path.setText(path) + + def start_collection(self): + if self.job is not None: + return + source = self.source.currentData() + if source == 'file' and not self.inventory_path.text().strip(): + self.status.setText('Choose an inventory file before analyzing.') + return + self.set_busy(True) + self.status.setText('Starting assessment…') + self.job = CollectionJob(source, self.target.currentData(), self.inventory_path.text().strip(), self) + self.job.stage.connect(self.status.setText) + self.job.result.connect(self.collection_done) + self.job.failed.connect(self.status.setText) + self.job.finished.connect(self.job_finished) + self.job.start() + + def set_busy(self, busy): + self.analyze_button.setEnabled(not busy) + self.source.setEnabled(not busy) + self.target.setEnabled(not busy and self.source.currentData() == 'local') + self.browse.setEnabled(not busy) + self.inventory_path.setEnabled(not busy) + self.cancel_button.setVisible(busy) + self.cancel_button.setEnabled(busy) + self.progress.setVisible(busy) + + def cancel_collection(self): + if self.job is not None: + self.job.requestInterruption() + self.cancel_button.setEnabled(False) + self.status.setText('Cancelling…') + + def source_label(self, data): + if data['synthetic']: + return 'Synthetic sample' + return data['server'].get('collection_source', 'Imported inventory — source not independently verified') + + def collection_done(self, data): + assessment = assess(data) + self.history.append({'hostname': data['server']['hostname'], 'risk': assessment['level'], + 'source': self.source_label(data), 'collected_at': data['collected_at']}) + self.records.append((data, assessment)) + self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}") + self.history_empty.hide() + self.display_assessment(data, assessment) + self.nav.setCurrentRow(0) + incomplete = sum(check['status'] != 'complete' for check in data['checks'].values()) + self.status.setText(f'Assessment report ready. {incomplete} checks need review. Open the Summary and detail tabs to review the report.') + + def display_assessment(self, data, assessment): + self.data, self.assessment = data, assessment + summary = summarize(data, assessment) + self.result_title.setText(data['server']['hostname']) + self.banner.setText(self.source_label(data) + ' • ' + data['collected_at']) + self.retirement_score.setText(f"Retirement risk estimate: {summary['risk_score']}/100 · {summary['risk_level'].title()}") + self.coverage_note.setText(f"{summary['incomplete']} checks incomplete or missing. This estimate does not establish retirement readiness.") + self.os_type.setText('OS type: ' + summary['os_type']) + self.os_version.setText('Version: ' + summary['version']) + software = summary['software'] + self.important_software.setText('\n'.join(f"• {row['name']} — {row['version']}" for row in software[:6]) + + (f"\n+ {len(software) - 6} more matches; see Software." if len(software) > 6 else '') if software else + 'No server-software name hints identified in collected evidence. Review the full Software and Services tabs.') + self.important_software.setToolTip('Name-based workload hints or running application services; not a complete application inventory and not proof of use.') + jobs = summary['jobs'] + self.scheduled_jobs.setText('\n'.join(f"• {row['name']} — {row['schedule']}" for row in jobs[:5]) + + (f"\n+ {len(jobs) - 5} more; see Tasks." if len(jobs) > 5 else '') + + '\nSchedules indicate configured work; execution is not confirmed.' if jobs else + 'No enabled schedules observed in collected evidence. Check Tasks and Collection checks for missing scheduler coverage.') + self.quick_save.setEnabled(True) + self.result_tabs.setCurrentIndex(0) + # Discard tables from the previous assessment. Populate detailed tabs on demand. + for area in self.detail_areas.values(): + while area.count(): + widget = area.takeAt(0).widget() + if widget: + widget.hide() + widget.deleteLater() + self.show_details() + self.report_label.setText('Current assessment: ' + data['server']['hostname'] + '\n' + self.source_label(data)) + for button in self.report_buttons: + button.setEnabled(True) + + def open_history(self, item): + index = self.history_list.row(item) + if 0 <= index < len(self.records): + self.display_assessment(*self.records[index]) + self.status.setText('Opened assessment from session history.') + self.nav.setCurrentRow(0) + + def show_details(self): + if self.data is None or self.result_tabs.currentIndex() == 0: + return + key = list(self.detail_areas)[self.result_tabs.currentIndex() - 1] + area = self.detail_areas[key] + if area.count(): + return + if key == 'overview': + rows = [{'property': name.replace('_', ' ').title(), 'value': str(value)} for name, value in self.data['server'].items()] + elif key == 'checks': + rows = [{'check': name, **value} for name, value in self.data['checks'].items()] + elif key in ('risk', 'dependencies'): + rows = self.assessment['findings'] + else: + rows = self.data['inventory'][key] + if key in self.data['checks']: + check = self.data['checks'][key] + area.addWidget(self.label(f"{check['status'].replace('_', ' ').title()}: {check['message']}", muted=True)) + area.addWidget(SearchTable(rows)) + + def job_finished(self): + self.job.deleteLater() + self.job = None + self.set_busy(False) + + def save_report(self, kind): + if self.data is None: + return + path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-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('Cancel the running assessment before closing.') + event.ignore() + else: + event.accept() diff --git a/schemas/inventory-v1.schema.json b/schemas/inventory-v1.schema.json index 8a5c720..c14757a 100644 --- a/schemas/inventory-v1.schema.json +++ b/schemas/inventory-v1.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Server Buddy inventory v1", + "title": "Samsara inventory v1", "type": "object", "required": [ "schema_version", diff --git a/server_buddy/main.py b/server_buddy/main.py deleted file mode 100644 index c64172c..0000000 --- a/server_buddy/main.py +++ /dev/null @@ -1,20 +0,0 @@ -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/reporting/export.py b/server_buddy/reporting/export.py deleted file mode 100644 index a44f64b..0000000 --- a/server_buddy/reporting/export.py +++ /dev/null @@ -1,23 +0,0 @@ -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

    " + "".join(f"
  • {escape(s)}
  • " for s in MANUAL_STEPS) + "
" - Path(path).write_text('Server Buddy' + content + '', encoding="utf-8") diff --git a/server_buddy/ui/window.py b/server_buddy/ui/window.py deleted file mode 100644 index 40ec27b..0000000 --- a/server_buddy/ui/window.py +++ /dev/null @@ -1,318 +0,0 @@ -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/test_core.py b/tests/test_core.py index 8f344bb..e786862 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,10 +2,10 @@ 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 +from samsara.core.validation import validate_hostname +from samsara.collectors.demo import collect_demo, DEMO_HOST +from samsara.analyzers.assessment import assess +from samsara.reporting.export import export_html, export_json class CoreTests(unittest.TestCase): def test_hostname(self): @@ -39,6 +39,8 @@ class CoreTests(unittest.TestCase): text = report.read_text() self.assertNotIn('