From 6e36df841ef414f1223839e511c6cf44cc341c57 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 6 Sep 2026 16:47:24 -0400 Subject: [PATCH] Import the wilm endpoint telemetry, PC Detail rework and backup overlay Ports 37 of Benjamin Baker's commits from the wilm branch, mirrored from the corporate GitHub at 7905917c. wilm rides the publication lineage and shares no ancestor with wj, so this is a filtered tree patch rather than a merge: 94 files, and every path the publication export strips (docs/, tools/, mcp/, CLAUDE.md, start-*.sh, .gitea/, the three excluded tests) is held back, because a raw diff would delete each of them from this tree. What comes across: * Endpoint telemetry, phase 2. Physical disk health from two sources, NIC link state, boot type, Defender health, processor/memory/chassis inventory, the listening TCP set, mapped network drives and Windows Update history. Five additive migrations, 0012 through 0016, off the 0011 head. * Mapped drives are collected by a probe running in the interactive user's session, because they do not exist in the agent's session 0. Two Windows sources can disagree about one drive and the worse status wins, so a half-broken share is never rendered as healthy. * Windows events are reported once each, keyed on the channel's own record id, instead of being re-sent whenever a report overlapped the last one. * PC Detail rework: Details cards flow, the hand-written Network card gives way to a full address history, Storage and disk health merge into one card with two sections, Operator Reports and Corporate Tools get their own tabs. * An opt-in restic backup overlay with a third container, restore gated on the dump's own data revision rather than the code head, and backup/restore history. * The agent's version is read from the binary's PE version rather than trusted from the csproj, and the Fleet view gains an Agent Version column. * The enqueue route now asks caller_holds instead of the owner's permissions, which closes the scoped-token elevation on run_script, update_app and system_restart. Held back deliberately: * agent/installer/install-shopdbagent.ps1 stays deleted, as upstream deleted it. The installer that reaches a bay is generated by _install_script() in plugins/endpoints/api/siteagent.py; the standalone script had stopped being shipped and was collecting fixes no endpoint ever ran. * The .gitignore hunk adding docs/*.md. Correct on the publication lineage, where docs/ becomes the wiki and its absence is how the gates detect a published checkout. Here it would silently ignore every new doc. * Their deletions of docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md. Upstream those were stubs; here PLUGIN-HOOKS.md is the 31KB document CLAUDE.md points readers at. * Five zero-byte files committed by an accidental `git add -A`, a run log from the author's own machine, and docs/notes/, which is working scratch that says so in its own header and belongs in devnotes/ on this side. Carried with the import so it lands green: the 17 new routes are described in docs/api-inventory.json with the spec and project map regenerated, and two new frontend specs read fixtures through fileURLToPath(URL) which node rejects under jsdom's own URL global. Suite 2591 passed / 9 skipped; frontend 391 passed; naming, style and doc gates clean. Scope: product --- .github/copilot-instructions.md | 10 + CONTRIBUTING.md | 31 +- agent/README.md | 25 +- agent/ShopDbEndpointAgent.Core/AgentConfig.cs | 68 +- .../CommandExecutor.cs | 294 +++++- .../ShopDbEndpointAgent.Core/ConsoleWindow.cs | 51 + agent/ShopDbEndpointAgent.Core/Contracts.cs | 46 + .../CuratedEventCatalog.cs | 8 + .../EndpointCollector.cs | 817 +++++++++++++++- .../EventWatermarkStore.cs | 132 +++ .../FileLoggerProvider.cs | 62 +- .../InteractiveProcessLauncher.cs | 96 +- .../ScheduledTaskPolicy.cs | 159 ++++ .../ShopDbEndpointAgent.Core.csproj | 4 + .../TcpTableReader.cs | 128 +++ .../TelemetryWorker.cs | 118 ++- .../UserContextProbe.cs | 362 +++++++ .../UserContextRefreshChannel.cs | 179 ++++ .../UserContextSpool.cs | 389 ++++++++ .../Program.cs | 51 + .../ShopDbEndpointAgent.PolicyProbe.csproj | 4 + .../ShopDbEndpointAgent.Tray.csproj | 61 +- agent/ShopDbEndpointAgent.Tray/TrayContext.cs | 339 +++++++ agent/ShopDbEndpointAgent/Program.cs | 147 ++- .../ShopDbEndpointAgent.csproj | 104 ++- agent/installer/install-shopdbagent.ps1 | 355 ------- agent/utilities/shopdb-dellupdates.ps1 | 119 +++ docker-compose.backup.yml | 175 ++++ docs/PROJECT-MAP.md | 11 +- docs/api-inventory.json | 143 ++- docs/openapi.json | 832 ++++++++++++++++- frontend/src/components/PluginAssetPanels.vue | 135 ++- .../src/components/emptyTablePanel.spec.js | 51 + frontend/src/components/panelGroups.spec.js | 124 +++ frontend/src/components/pinnedPanels.spec.js | 211 +++++ frontend/src/components/pluginAssetPanels.js | 62 +- .../src/components/pluginAssetPanels.spec.js | 21 +- .../src/components/tabSectionsTable.spec.js | 99 ++ frontend/src/router/routes/core.js | 6 + frontend/src/utils/siteSettings.js | 53 +- frontend/src/utils/siteSettings.spec.js | 119 +++ .../src/views/settings/ShopdbBackupsList.vue | 864 +++++++++++++++++ frontend/src/views/settings/settingsNav.js | 3 +- plugins/computers/frontend/views/PCDetail.vue | 883 ++++++++++++++---- plugins/computers/frontend/views/PCForm.vue | 12 +- plugins/endpoints/AGENT-CONTRACT.md | 251 ++++- plugins/endpoints/api/agentupload.py | 37 +- plugins/endpoints/api/routes.py | 767 ++++++++++++++- plugins/endpoints/api/siteagent.py | 237 ++++- plugins/endpoints/commands.py | 117 ++- plugins/endpoints/eventcatalog.py | 7 + .../frontend/views/AgentKeySettings.vue | 40 +- .../frontend/views/EndpointsList.vue | 4 +- .../versions/0012_endpoint_eventrecordid.py | 80 ++ .../0013_endpoint_inventory_columns.py | 93 ++ .../versions/0014_endpoint_diskhealth.py | 56 ++ .../0015_endpoint_system_telemetry.py | 78 ++ .../versions/0016_endpoint_dellupdates.py | 51 + plugins/endpoints/models/endpoint.py | 74 ++ plugins/endpoints/plugin.py | 372 +++++++- .../machines/frontend/views/MachineForm.vue | 8 +- .../frontend/views/MeasuringToolForm.vue | 5 +- scripts/backup/.gitattributes | 4 + scripts/backup/Dockerfile.backup | 38 + scripts/backup/SIMULATE-RESTORE.README.md | 44 + scripts/backup/backup.sh | 148 +++ scripts/backup/manifest.py | 132 +++ scripts/backup/restore.sh | 133 +++ scripts/backup/simulate-restore.sh | 235 +++++ scripts/backup/status.sh | 100 ++ scripts/backup/test-restore-datarev.sh | 172 ++++ scripts/backup/watch.sh | 61 ++ scripts/check-naming-and-style.sh | 4 + scripts/uninstall-shopdbagent.ps1 | 37 +- shopdb/__init__.py | 1 + shopdb/api/__init__.py | 2 +- shopdb/config.py | 9 +- shopdb/core/api/__init__.py | 2 + shopdb/core/api/pluginui.py | 9 +- shopdb/core/api/settings.py | 108 +++ shopdb/core/api/shopdbbackups.py | 590 ++++++++++++ tests/test_core/test_error_envelope.py | 2 +- tests/test_core/test_pluginui_panel_groups.py | 44 + tests/test_docs_publishable.py | 18 +- tests/test_docs_versions.py | 9 +- tests/test_plugin_migrations.py | 7 +- .../test_endpoints_agentupload.py | 90 ++ .../test_endpoints_dellupdates.py | 168 ++++ .../test_endpoints_disk_health.py | 108 +++ .../test_plugins/test_endpoints_ip_history.py | 138 +++ .../test_plugins/test_endpoints_link_label.py | 49 + .../test_endpoints_mapped_drives.py | 186 ++++ .../test_endpoints_mapped_drives_card.py | 118 +++ .../test_endpoints_panel_groups.py | 74 ++ tests/test_plugins/test_endpoints_plugin.py | 25 +- .../test_endpoints_storage_sections.py | 51 + 96 files changed, 12395 insertions(+), 761 deletions(-) create mode 100644 agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs create mode 100644 agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs create mode 100644 agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs create mode 100644 agent/ShopDbEndpointAgent.Core/TcpTableReader.cs create mode 100644 agent/ShopDbEndpointAgent.Core/UserContextProbe.cs create mode 100644 agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs create mode 100644 agent/ShopDbEndpointAgent.Core/UserContextSpool.cs delete mode 100644 agent/installer/install-shopdbagent.ps1 create mode 100644 agent/utilities/shopdb-dellupdates.ps1 create mode 100644 docker-compose.backup.yml create mode 100644 frontend/src/components/emptyTablePanel.spec.js create mode 100644 frontend/src/components/panelGroups.spec.js create mode 100644 frontend/src/components/pinnedPanels.spec.js create mode 100644 frontend/src/components/tabSectionsTable.spec.js create mode 100644 frontend/src/utils/siteSettings.spec.js create mode 100644 frontend/src/views/settings/ShopdbBackupsList.vue create mode 100644 plugins/endpoints/migrations/versions/0012_endpoint_eventrecordid.py create mode 100644 plugins/endpoints/migrations/versions/0013_endpoint_inventory_columns.py create mode 100644 plugins/endpoints/migrations/versions/0014_endpoint_diskhealth.py create mode 100644 plugins/endpoints/migrations/versions/0015_endpoint_system_telemetry.py create mode 100644 plugins/endpoints/migrations/versions/0016_endpoint_dellupdates.py create mode 100644 scripts/backup/.gitattributes create mode 100644 scripts/backup/Dockerfile.backup create mode 100644 scripts/backup/SIMULATE-RESTORE.README.md create mode 100644 scripts/backup/backup.sh create mode 100644 scripts/backup/manifest.py create mode 100644 scripts/backup/restore.sh create mode 100644 scripts/backup/simulate-restore.sh create mode 100644 scripts/backup/status.sh create mode 100644 scripts/backup/test-restore-datarev.sh create mode 100644 scripts/backup/watch.sh create mode 100644 shopdb/core/api/shopdbbackups.py create mode 100644 tests/test_core/test_pluginui_panel_groups.py create mode 100644 tests/test_plugins/test_endpoints_agentupload.py create mode 100644 tests/test_plugins/test_endpoints_dellupdates.py create mode 100644 tests/test_plugins/test_endpoints_disk_health.py create mode 100644 tests/test_plugins/test_endpoints_ip_history.py create mode 100644 tests/test_plugins/test_endpoints_link_label.py create mode 100644 tests/test_plugins/test_endpoints_mapped_drives.py create mode 100644 tests/test_plugins/test_endpoints_mapped_drives_card.py create mode 100644 tests/test_plugins/test_endpoints_panel_groups.py create mode 100644 tests/test_plugins/test_endpoints_storage_sections.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8892e585..64c6c533 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,16 @@ Follow these when suggesting code. They are enforced by a naming/style hook and by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build. `CONTRIBUTING.md` is the full authority; this is the short version. +## General Guidelines +- Finish the in-flight task before starting new workstreams. +- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies. +- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F `. +- Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined. + +## Installer Logic +- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers. +- The user's workflow: the agent hands over only the built EXEs (`shopdbagent.exe`, `shopdb-endpoint-tray.exe`); the user uploads them via the "Upload New/Legacy Agents" field in the Build Site Agent UI and builds the package with the newest version selected at the top. + ## Naming (LOCKED - the hook rejects violations) - **DB tables**: lowercase, concatenated, plural. No underscores, no dashes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83a739fe..5fddca00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,32 +171,15 @@ Tests are required for: ## Branches: what belongs on main, and what does not -`main` is the curated product other sites adopt, and what the publication -mirror exports. It is a release decision: something reaching `main` says other -sites should take it. **`main` is not what this site runs.** - -Each branch answers to one instance: - -| branch | runs on | contains | -|---|---|---| -| `dev` | the dev instance | work being validated | -| `wj` | this site's PRODUCTION | everything this site runs | -| `main` | nothing directly | the curated release for other sites | -| `wilm` | the other site | their work, on the corporate GitHub | - -So work flows `dev` -> `wj` -> `main`: land it on `dev`, validate it on the dev -instance, take it to `wj` for production, and promote to `main` what you are -happy for another site to run. Promoting first and back-filling the site branch -is the wrong way round - it puts unvalidated work in front of other sites and -leaves production behind. - -`wilm` is not visible from the dev box because that machine has no account on -the corporate GitHub, which is why `fetch-wilm-branch.cmd` exists to bring it -back for review. It sits on the publication lineage, so it shares no ancestor -with `main`/`wj`/`dev` and cannot be merged locally. +`main` is the product every site runs and the publication mirror exports. +`` branches hold work that is not ready for other sites to see. There are +two: `wj` here, and `wilm` on the corporate GitHub, where the other site +develops the .NET endpoint agent. A site branch is not a fork. Both feed `main`, and the sooner they do it the -cheaper it is. +cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not +visible from here because this machine has no GitHub account, which is why +`fetch-wilm-branch.cmd` on the share exists to bring it back for review. **Most work is product.** ADR-015 gives site differences three homes on `main`: a setting with a neutral default, seed data, or a site-namespaced directory diff --git a/agent/README.md b/agent/README.md index 0c43efe5..c54c7282 100644 --- a/agent/README.md +++ b/agent/README.md @@ -84,12 +84,23 @@ event channel and system WMI. ## Tray app (system-tray icon) The tray is a per-user process, not a service, so it starts at sign-in rather -than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies -`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart -(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and -launches it for the current user. Run the installer from the operator's own -session so the autostart lands in the right user hive. Pass `-NoTray` for a -headless, service-only install. +than under `sc.exe`. The generated `install.ps1` handles it: it copies +`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon +autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`, +64-bit view). The installer deliberately does NOT start the tray itself: it runs +elevated, so any process it starts inherits an admin token, and mapped drives are +per logon token - an elevated tray probes the wrong drive table. The Run key only +fires at logon, which would leave the tray dead until sign-out on a machine +someone is already using, so the installer also registers a transient +`ShopDB tray first launch` scheduled task under `S-1-5-32-545` at `RunLevel +Limited`, runs it once, and unregisters it. That task is the only mechanism that +can reach into an existing interactive session without carrying the installer's +token. Pass `-NoTray` for a headless, service-only install. + +User-context collection does not depend on the tray. The installer registers a +`ShopDB user-context probe` scheduled task against the Users group at +`RunLevel Limited`, which runs `shopdbagent.exe --collect-usercontext` in the +operator's own session at logon and every 30 minutes. Right-click the tray icon for: @@ -143,7 +154,7 @@ logic itself changes. ## Diagnostics -- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log` +- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log` (14-day retention). - Windows Event Log: agent runtime failures and unhandled exceptions are written to the `Application` log under source `ShopDB Endpoint Agent`. The source is diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 991d857e..92320022 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -89,38 +89,44 @@ public sealed class AgentConfig return bare; } - // Per-name allowlist of pre-staged scripts for the generic run_script - // action. Exactly like LaunchPaths, the wire never carries a script body or - // path - only a script NAME the agent already holds here. A name absent - // from this map fails closed. There are no shipped defaults: a script is a - // site-specific artifact, so an empty map means run_script does nothing - // until a site stages scripts and lists them in appsettings.json. Keyed by - // script name (case-insensitive, .ps1 optional). - [JsonPropertyName("scriptpaths")] - public Dictionary ScriptPaths { get; set; } = new(); + // REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths. + // + // This used to be a per-name map of PRE-STAGED scripts on the endpoint + // (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which + // run_script would look up by name and execute. It was superseded by + // server-hosted delivery and left behind as dead config: nothing read the + // map, so it was an execution path that appeared supported but was not. + // + // It is gone rather than merely unused because reviving it is a security + // regression, and dead code invites revival. That model had NO integrity + // check - no digest, no provenance - so whoever could write into the staging + // directory chose what the agent ran as SYSTEM. ProgramData is world- + // writable by default unless the tree is explicitly tightened, which made + // the gate "is this name in appsettings.json", not "is this content + // trusted". + // + // The supported path is run_script with a server-issued delivery descriptor: + // the body is fetched from ShopDB over the collector credential and verified + // against a server-pinned SHA-256 before it executes, so the content is + // authenticated and the action is attributable in the command log. If you + // need a script to run on an endpoint, publish it server-side and send it + // through that path. If you need to trigger something already installed on + // the box, use scheduledtask_run, which is far narrower. - // The effective script map: appsettings.json entries only (no defaults). - // Keys are normalized to bare names so a lookup tolerates 'reset' or - // 'reset.ps1'. - public IReadOnlyDictionary ResolveScriptPaths() - { - var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var pair in ScriptPaths) - { - if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value)) - continue; - resolved[NormalizeScript(pair.Key)] = pair.Value; - } - return resolved; - } - - private static string NormalizeScript(string name) - { - var bare = (name ?? "").Trim(); - if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) - bare = bare[..^4]; - return bare; - } + // Approved scheduled task paths for the scheduledtask_run action. The wire + // carries a free-text task path so the WebUI can expose it as a single + // field; ScheduledTaskPolicy validates it against this list and fails closed + // when it does not match. An entry matches that exact task path, or acts as + // a folder prefix for anything beneath it. + // + // Empty means "use the shipped Corporate Tools defaults" + // (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those + // defaults, so a site can narrow the set as well as widen it without an + // agent rebuild. Keep it tight: every entry here is something an operator + // holding endpoints.deploy can start, in whatever context the task itself + // registered. + [JsonPropertyName("approvedtaskpaths")] + public List ApprovedTaskPaths { get; set; } = new(); public bool IsUsable() => !string.IsNullOrWhiteSpace(BaseUrl) diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index 7db7a198..99277899 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -64,6 +64,10 @@ public sealed class CommandExecutor // before they are honored. Local launchPaths always win over a server path. private readonly LaunchPathPolicy launchPathPolicy; + // Validates the free-text task path carried by scheduledtask_run before the + // agent will start anything. Fails closed: an unapproved path never runs. + private readonly ScheduledTaskPolicy scheduledTaskPolicy; + // Held for update_app: the agent joins InstallPath to BaseUrl and downloads // the pinned installer with its own collector credential (ApiKey), so the // installer body never rides the command wire. @@ -77,6 +81,7 @@ public sealed class CommandExecutor this.config = config ?? new AgentConfig(); this.launchPaths = this.config.ResolveLaunchPaths(); this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots); + this.scheduledTaskPolicy = new ScheduledTaskPolicy(this.config.ApprovedTaskPaths); } public async Task ExecuteAsync( @@ -116,6 +121,16 @@ public sealed class CommandExecutor return RequireTarget(action, target) ?? await ProcessStart(target, token); + // Start an already-registered scheduled task by path. Deliberately + // narrow: the agent does not create, modify, or define tasks, it + // only starts one that already exists AND clears the approved-task + // policy. Prefer this over run_script whenever the work is already + // packaged as a task - it is a far smaller grant than shipping a + // script body, and the command log names exactly what ran. + case "scheduledtask_run": + return RequireTarget(action, target) + ?? await ScheduledTaskRun(target, token); + // Software delivery. run_script fetches a ShopDB-hosted script // pinned by SHA-256 and runs it in the script's configured context // (system or the interactive user); update_app fetches a ShopDB @@ -144,6 +159,14 @@ public sealed class CommandExecutor Message = "Fresh telemetry collection requested", }; + // Full TCP connection table, on demand only. Deliberately not part of + // the standard report: established connections churn every second, so + // sending them on cadence would bloat every payload and swamp the + // stable listener list. As a command the technician pays that cost + // once, at the moment they are actually diagnosing connectivity. + case "network_connections": + return NetworkConnections(); + default: logger.LogWarning("Rejected unknown command action {Action}", action); return new CommandResult @@ -154,6 +177,98 @@ public sealed class CommandExecutor } } + // Snapshot of every TCP connection with its owning process. Capped because a + // busy machine can hold thousands of sockets and an unbounded result would + // be rejected by the server's payload limit, returning nothing at all - + // a truncated answer is far more useful than a failed one. + private const int MaxReportedConnections = 500; + + private CommandResult NetworkConnections() + { + try + { + var rows = TcpTableReader.GetTcpTable(); + var names = GetProcessNameSnapshot(); + var connections = new List(); + var truncated = false; + + foreach (var row in rows) + { + if (connections.Count >= MaxReportedConnections) + { + truncated = true; + break; + } + + names.TryGetValue(row.ProcessId, out var processName); + connections.Add(new Dictionary + { + ["localaddress"] = row.LocalAddress, + ["localport"] = row.LocalPort, + ["remoteaddress"] = row.RemoteAddress, + ["remoteport"] = row.RemotePort, + ["state"] = row.State.ToString().ToLowerInvariant(), + ["processid"] = row.ProcessId, + ["processname"] = processName, + }); + } + + logger.LogInformation( + "network_connections returned {Count} connections (truncated: {Truncated})", + connections.Count, truncated); + + return new CommandResult + { + Succeeded = true, + Message = $"Collected {connections.Count} TCP connections" + + (truncated ? " (truncated)" : ""), + Detail = new Dictionary + { + ["connections"] = connections, + ["totalcount"] = rows.Count, + ["truncated"] = truncated, + }, + }; + } + catch (Exception exception) + { + logger.LogWarning(exception, "network_connections failed"); + return new CommandResult + { + Succeeded = false, + Message = "Could not read the TCP connection table: " + exception.Message, + }; + } + } + + private Dictionary GetProcessNameSnapshot() + { + var names = new Dictionary(); + try + { + foreach (var process in Process.GetProcesses()) + { + try + { + names[process.Id] = process.ProcessName; + } + catch + { + // The process exited between enumeration and read. + } + finally + { + process.Dispose(); + } + } + } + catch (Exception exception) + { + logger.LogDebug(exception, "Process name snapshot failed"); + } + return names; + } + // Guard: a generic action with no target has nothing to act on. Returns a // failed result to short-circuit, or null when the target is present. private CommandResult? RequireTarget(string action, string target) @@ -550,6 +665,139 @@ public sealed class CommandExecutor return found; } + // ---- Scheduled tasks ---- + + // Upper bound on how long a task start may block the command worker. This + // waits only for schtasks.exe to hand the request to the Task Scheduler + // service, NOT for the task's own work to finish - schtasks /run returns as + // soon as the task is launched. So success here means "the task was + // started", not "the task succeeded", and the timeout only needs to cover + // the handoff. + private static readonly TimeSpan ScheduledTaskStartTimeout = TimeSpan.FromSeconds(30); + + // Start an already-registered scheduled task by path, after the policy + // approves it. Uses schtasks.exe with ArgumentList (no shell, no string + // concatenation) so the task path is passed as a single argv entry and + // cannot break out into additional arguments. + // + // The task runs in whatever security context its own registered definition + // specifies - SYSTEM, an interactive user, whatever principal was set when + // the task was created. The agent does not and cannot change that, which is + // exactly why this action is narrower than run_script: the endpoint owns the + // definition of what happens, the server only asks for it to happen now. + private async Task ScheduledTaskRun(string target, CancellationToken token) + { + if (!scheduledTaskPolicy.TryValidate(target, out var taskPath, out var reason)) + { + logger.LogWarning( + "Rejected scheduledtask_run for {Target}: {Reason}", target, reason); + return new CommandResult + { + Succeeded = false, + Message = $"Scheduled task '{target}' rejected: {reason}", + }; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "schtasks.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.ArgumentList.Add("/run"); + startInfo.ArgumentList.Add("/tn"); + startInfo.ArgumentList.Add(taskPath); + + using var process = Process.Start(startInfo); + if (process is null) + { + return new CommandResult + { + Succeeded = false, + Message = $"Failed to start schtasks.exe for task {taskPath}", + }; + } + + var stdout = ""; + var stderr = ""; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(ScheduledTaskStartTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + // Read only after exit. Reading to end first would block until + // the streams close - which IS process exit - so the timeout + // above could never fire. Safe here because schtasks emits a + // line or two, far below the pipe buffer that would deadlock a + // chattier child. + stdout = await process.StandardOutput.ReadToEndAsync(token); + stderr = await process.StandardError.ReadToEndAsync(token); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + TryKill(process); + return new CommandResult + { + Succeeded = false, + Message = $"Timed out starting scheduled task {taskPath}", + }; + } + + var exit = process.ExitCode; + var ok = exit == 0; + // schtasks writes the useful diagnosis (task not found, access + // denied, disabled) to its output, so surface it rather than making + // the operator guess from a bare exit code. + var detail = (stderr.Trim().Length > 0 ? stderr.Trim() : stdout.Trim()); + if (ok) + { + logger.LogInformation("Started scheduled task {TaskPath}", taskPath); + } + else + { + logger.LogWarning( + "schtasks /run for {TaskPath} exited {Exit}: {Detail}", + taskPath, exit, detail); + } + + return new CommandResult + { + Succeeded = ok, + Message = ok + ? $"Started scheduled task {taskPath}" + : $"Scheduled task {taskPath} failed to start (exit {exit})" + + (detail.Length > 0 ? $": {detail}" : ""), + }; + } + catch (Exception error) + { + logger.LogWarning(error, "scheduledtask_run for {TaskPath} failed", taskPath); + return new CommandResult + { + Succeeded = false, + Message = $"Scheduled task {taskPath} failed: {error.Message}", + }; + } + } + + private void TryKill(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not kill timed-out schtasks.exe"); + } + } + // ---- Software delivery: run_script and update_app ---- // Run a ShopDB-hosted script pinned by the server's delivery descriptor. The @@ -559,6 +807,26 @@ public sealed class CommandExecutor // 'system' through the agent's own service account, or 'user' in the active // console session so the script sees the interactive user's resources (for // example mapped network drives). Anything missing or mismatched fails closed. + // + // SECURITY CONTRACT - read before changing this method. This is arbitrary + // code execution on the endpoint, by design, and the agent is a SYSTEM + // service: a script whose descriptor does not pin the user context runs with + // full machine privilege. Three properties make that acceptable, and all + // three are load-bearing: + // + // 1. The body is never carried on the command wire. The agent fetches it + // itself from ShopDB using its own collector credential, so queueing a + // command cannot smuggle in code. + // 2. The body is verified against the server-pinned SHA-256 before it + // runs. A tampered or swapped body fails closed. + // 3. The action is gated on endpoints.deploy server-side and is meant to + // sit behind an explicit UI confirmation, the same as system_restart. + // + // Do not add a path that runs a script the agent found locally rather than + // fetched and verified - that was the old pre-staged 'scriptpaths' model and + // it was removed on purpose (see the tombstone in AgentConfig). If the goal + // is to trigger something already installed on the endpoint, use + // scheduledtask_run instead: it is a much smaller grant. private async Task RunScript( string name, ScriptDeliveryClaim? script, CancellationToken token) { @@ -714,6 +982,12 @@ public sealed class CommandExecutor // to exit and returns its real exit code, so success here means the script // actually ran to a zero exit - not merely that it was launched. Fails closed // when no interactive user is signed in. + // Upper bound on how long a user-session script may block the command + // worker. Generous, because a staged maintenance script can legitimately run + // for minutes, but finite: before this existed the wait was Infinite and a + // hung script pinned the caller forever. + private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5); + private CommandResult RunScriptInUserSession( string name, ScriptDeliveryClaim script, string scriptPath) { @@ -723,7 +997,8 @@ public sealed class CommandExecutor var workingDirectory = Path.GetDirectoryName(scriptPath) ?? ""; var outcome = InteractiveProcessLauncher.LaunchInActiveSession( - "powershell.exe", arguments, workingDirectory, waitForExit: true); + "powershell.exe", arguments, workingDirectory, waitForExit: true, + waitTimeout: UserScriptWaitTimeout); if (!outcome.Launched) { logger.LogWarning( @@ -737,6 +1012,23 @@ public sealed class CommandExecutor }; } + if (outcome.TimedOut) + { + // The script is still running; we simply stopped waiting. Report it + // as unresolved rather than success, because we never saw an exit + // code and cannot claim it worked. + logger.LogWarning( + "run_script for {Name} still running after {Timeout}; stopped waiting", + name, UserScriptWaitTimeout); + return new CommandResult + { + Succeeded = false, + Message = $"Script {name} did not finish within " + + $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running", + Detail = ScriptDetail(name, script, scriptPath, exitCode: null), + }; + } + var exit = outcome.ExitCode; var ok = exit is null || exit == 0; return new CommandResult diff --git a/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs b/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs new file mode 100644 index 00000000..1c4f8e90 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs @@ -0,0 +1,51 @@ +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// Hides the console window this process was given. +// +// shopdbagent.exe is a console executable, so anything that starts it in an +// interactive session gets a visible black window. That was invisible while the +// tray owned the probe - it launched with CreateNoWindow - but the user-context +// scheduled task runs the exe directly in the operator's session, where the +// window is very much visible and pops up on every collection cycle. +// +// The window is hidden rather than the exe rebuilt as a WinExe: the agent is +// also a service host and a command-line tool, and both of those want a real +// console for --once, --report and service debugging. +public static class ConsoleWindow +{ + private const int SW_HIDE = 0; + + [DllImport("kernel32.dll")] + private static extern nint GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(nint window, int command); + + // Best-effort: a process with no console (already detached, or redirected by + // a caller) simply has nothing to hide, and failing to hide a window must + // never stop the collection that window belongs to. + public static void Hide() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + try + { + var window = GetConsoleWindow(); + if (window != nint.Zero) + { + ShowWindow(window, SW_HIDE); + } + } + catch + { + // Nothing useful to do or say: there is no log sink yet at the point + // this runs, and a visible window is cosmetic next to losing data. + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index d1c18f0f..911ad397 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -44,11 +44,57 @@ public sealed class EndpointReport // collection: both Uninstall registry hives, {displayname, displayversion, // publisher}. Server logs it as an unpersisted key until the SBOM store lands. [JsonPropertyName("applications")] public object[]? Applications { get; set; } + + // True when Windows is holding a reboot. A machine that has been waiting to + // reboot for weeks explains stalled patching and "the fix did not take" + // reports, and on a shop floor PC nobody reboots voluntarily. + [JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; } + + // Which signal(s) drove pendingreboot, so the server can tell a box staged + // mid-servicing (pendingfilerename) from one with a stale servicing key. + // Empty array = checked, nothing pending. Omitted = the check failed. + [JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; } + + // hiberbootenabled (fast startup configured) and lastboottype (what the + // last boot actually did). Uptime is not trustworthy when these disagree. + [JsonPropertyName("bootinfo")] public object? BootInfo { get; set; } + + // Defender health. Persisted but deliberately not surfaced: known false + // positives in this environment. + [JsonPropertyName("defender")] public object? Defender { get; set; } + + // Physical drive health from two sources: MSFT_PhysicalDisk (the only one + // that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback). + // Each entry carries a precomputed predictfailure so an alert is a boolean + // test rather than a string match against an enum. Null predictfailure + // means the drive did not answer - unknown, not healthy. + [JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; } + + // Session-scoped data gathered by a probe running as the signed-in user: + // mapped drives and user-owned listeners. Null when nobody is signed in or + // the probe did not produce a result this cycle - absent means "not + // observed", which is deliberately distinct from an empty list. + [JsonPropertyName("usercontext")] public object? UserContext { get; set; } + + // Pending Dell Command | Update results, produced by the shopdb-dellupdates + // agent utility rather than collected in C#: the data comes from dcu-cli's + // own scan output, so the utility that parses it belongs next to the task + // that writes it. Null means no scan has ever run (or the utility is not + // installed), which is deliberately distinct from a scan that found nothing + // pending - the first is "unknown", the second is "known clean". + [JsonPropertyName("dellupdates")] public object? DellUpdates { get; set; } } public sealed class EndpointEventRecord { [JsonPropertyName("eventid")] public int EventId { get; set; } + + // The Windows event record id: monotonic and unique within its channel on a + // given machine. It is both the agent's harvest watermark and the server's + // dedupe key, so the same record can never be stored twice. Null only when + // the source record did not expose one. + [JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; } + [JsonPropertyName("eventchannel")] public string? EventChannel { get; set; } [JsonPropertyName("eventsource")] public string? EventSource { get; set; } [JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; } diff --git a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs index 57b44d7f..143ce78d 100644 --- a/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs +++ b/agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs @@ -27,7 +27,15 @@ public static class CuratedEventCatalog new(55, "System", "NTFS file system corruption"), new(7011, "System", "Service Control Manager timeout"), new(10016, "System", "DCOM permission error"), + // Windows Error Reporting on the Application channel: 1000 is the + // faulting-application record (app, version, faulting module, exception + // code), 1001 is the WER bucket record Windows pairs with it, and 1002 + // is an unresponsive app rather than a crash. The structured detail for + // all three arrives in the record's EventData, which the collector lifts + // into `fields`. + new(1000, "Application", "Application error / faulting module"), new(1001, "Application", "Application error / bugcheck report"), + new(1002, "Application", "Application hang"), }; // The distinct channels the collector must read. diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 2486a66e..08604605 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.Eventing.Reader; using System.Management; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -19,16 +20,28 @@ public sealed class EndpointCollector : ICollector // keep the payload bounded on a chatty Security log. private const int MaxEventsPerChannel = 25; + // The spool file is read every cycle. Reading it is a small file read, and + // the tray - not this cadence - decides how often the data is refreshed, so + // throttling the read here would only delay already-collected data reaching + // the server without saving any real work. + private readonly ILogger logger; private readonly AgentConfig config; private readonly IAgentUtilityRunner? utilities; + private readonly EventWatermarkStore? watermarks; + private readonly UserContextSpool? userContextSpool; + + private object? lastUserContext; public EndpointCollector(ILogger logger, AgentConfig config, - IAgentUtilityRunner? utilities = null) + IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null, + UserContextSpool? userContextSpool = null) { this.logger = logger; this.config = config; this.utilities = utilities; + this.watermarks = watermarks; + this.userContextSpool = userContextSpool; } public EndpointReport Collect(string agentVersion, bool adopt) @@ -49,23 +62,232 @@ public sealed class EndpointCollector : ICollector MachineNumber = GetMachineNumber(), LastBootTime = GetLastBootTime(), Services = GetMonitoredTargets(), + Ports = GetListeningPorts(), Interfaces = GetInterfaces(), Events = GetCuratedEvents(), Disks = GetDisks(), + DiskHealth = GetDiskHealth(), Bios = GetBios(), Memory = GetMemory(), Processor = GetProcessor(), OsInfo = GetOsInfo(), Domain = GetDomain(), Applications = GetApplications(), + PendingReboot = GetPendingReboot(), + RebootReasons = GetRebootReasons(), + BootInfo = GetBootInfo(), + Defender = GetDefender(), + UserContext = GetUserContext(), }; if (report.LastBootTime is DateTimeOffset boot) report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds; + report.CpuPercent = GetCpuPercent(); + report.MemoryPercent = GetMemoryPercent(); + report.IdleSeconds = GetIdleSecondsFromUserContext(); + return report; } + // Instantaneous CPU load from the _Total performance instance. + // + // Uses the WMI formatted-data class rather than PerformanceCounter because a + // PerformanceCounter reads 0 on its first sample and needs a second sample a + // second later to mean anything. Sleeping a second inside the collect path + // would delay the heartbeat on every tick for one number. The formatted-data + // class is already averaged by the OS, so a single read is usable. + private double? GetCpuPercent() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT PercentProcessorTime FROM " + + "Win32_PerfFormattedData_PerfOS_Processor WHERE Name='_Total'"); + foreach (var mo in searcher.Get()) + { + var value = ToLong(mo["PercentProcessorTime"]); + if (value is long percent) + return Math.Clamp(percent, 0, 100); + } + } + catch (Exception error) + { + logger.LogDebug(error, "CPU percent query failed"); + } + return null; + } + + // Percentage of physical memory in use. Reported alongside the absolute + // byte counts in the memory object because the percentage is what an alert + // threshold keys off, and deriving it server-side would break whenever the + // byte fields are absent. + private double? GetMemoryPercent() + { + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT TotalVisibleMemorySize, FreePhysicalMemory " + + "FROM Win32_OperatingSystem"); + foreach (var mo in searcher.Get()) + { + var totalKb = ToLong(mo["TotalVisibleMemorySize"]); + var freeKb = ToLong(mo["FreePhysicalMemory"]); + if (totalKb is long total && total > 0 && freeKb is long free) + { + var used = (double)(total - free) / total * 100.0; + return Math.Round(Math.Clamp(used, 0, 100), 1); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Memory percent query failed"); + } + return null; + } + + // Idle time is measured by the user-context probe, because GetLastInputInfo + // is per session and this service runs in session 0. Null when no probe + // result is available, which correctly reads as "not observed" rather than + // "the machine has been idle for zero seconds". + private long? GetIdleSecondsFromUserContext() + { + if (lastUserContext is not Dictionary context) + return null; + if (!context.TryGetValue("idleseconds", out var raw) || raw is null) + return null; + + try + { + if (raw is JsonElement element) + { + return element.ValueKind == JsonValueKind.Number + && element.TryGetInt64(out var parsed) ? parsed : null; + } + return Convert.ToInt64(raw); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not read idleseconds from the user context"); + return null; + } + } + + // Listening TCP ports with the owning process name. + // + // Only listeners, never established connections: listeners answer "what + // service is this machine offering" (the DNC port, the MTConnect adapter), + // which is stable and small. Established connections churn constantly and + // would make every report differ from the last for no diagnostic gain, so + // the full table is exposed as an on-demand command instead. + private const int MaxListeners = 100; + + private object[] GetListeningPorts() + { + try + { + var listeners = TcpTableReader.GetListeners(); + if (listeners.Count == 0) + return Array.Empty(); + + var names = GetProcessNames(); + var result = new List(); + + foreach (var listener in listeners) + { + if (result.Count >= MaxListeners) + { + logger.LogDebug( + "Listener list truncated at {Limit} entries.", MaxListeners); + break; + } + + names.TryGetValue(listener.ProcessId, out var processName); + result.Add(new + { + localaddress = listener.LocalAddress, + localport = listener.LocalPort, + processid = listener.ProcessId, + processname = processName, + protocol = "tcp", + }); + } + + return result.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Listening port enumeration failed"); + return Array.Empty(); + } + } + + // Snapshot the process table once and index by id. Resolving each listener + // individually would be an O(n) call per row and can race with process exit. + private Dictionary GetProcessNames() + { + var names = new Dictionary(); + try + { + foreach (var process in Process.GetProcesses()) + { + try + { + names[process.Id] = process.ProcessName; + } + catch + { + // The process exited between enumeration and read. + } + finally + { + process.Dispose(); + } + } + } + catch (Exception error) + { + logger.LogDebug(error, "Process name snapshot failed"); + } + return names; + } + + // Session-scoped data, read from the spool file the tray writes. The service + // only reads: it cannot collect this itself, because mapped drives and idle + // time do not exist in session 0. Between reads the previous result is + // repeated, because a mapped drive stays mapped and dropping the key would + // read on the server as "the drive went away". The spool's own age limit is + // what eventually retires a result, so a logged-off machine goes quiet + // instead of reporting the last operator forever. + private object? GetUserContext() + { + if (userContextSpool is null) + return null; + + var resultPath = userContextSpool.TryGetResultPath(out var reason); + if (resultPath is null) + { + logger.LogDebug("Skipping the user-context read: {Reason}", reason); + return lastUserContext; + } + + // Peek rather than consume: the probe overwrites the spool on every run, + // so the newest result is always the one on disk. The file deliberately + // survives a service restart, which the previous delete-on-read did not - + // lastUserContext is in-memory only, so a restart used to leave the agent + // with no drives to report until the tray's next probe up to 30 minutes + // later. lastUserContext still repeats the previous value between reads, + // because the tray collects on a slower cadence than telemetry and + // dropping the key in between would read on the server as "the drives + // went away". + var collected = userContextSpool.ConsumeResult(resultPath); + if (collected is not null) + lastUserContext = collected; + + return lastUserContext; + } + // The interactive tray app ships and updates independently of this service, // so the agent reports the tray's file version as its own field. Resolve the // tray exe beside the agent first (the normal install layout), then the @@ -296,6 +518,7 @@ public sealed class EndpointCollector : ICollector private object[] GetInterfaces() { var result = new List(); + var linkstate = GetLinkState(); try { using var searcher = new ManagementObjectSearcher( @@ -321,6 +544,10 @@ public sealed class EndpointCollector : ICollector var gateway = gateways is null || gateways.Length == 0 ? null : gateways[0]; + // Paired by MAC from MSFT_NetAdapter. Absent when that class is + // unavailable, which must leave the rest of the entry intact. + linkstate.TryGetValue(NormalizeMac(mac) ?? "", out var link); + result.Add(new { alias = mo["Description"]?.ToString()?.Trim() ?? "", @@ -332,6 +559,10 @@ public sealed class EndpointCollector : ICollector gateway = gateway ?? "", subnet = subnet ?? "", dhcp = mo["DHCPEnabled"] as bool? ?? false, + // Null rather than 0/false when link state was not + // readable: an unknown duplex must not read as half. + linkspeedbps = link.Speed, + fullduplex = link.FullDuplex, }); } } @@ -451,40 +682,33 @@ public sealed class EndpointCollector : ICollector } } - // Walk each curated channel newest-first, keep only the curated IDs for - // that channel, cap per channel. Reads Security/System/Application - the - // old scaffold read System only and filtered by the wrong IDs. + // Walk each curated channel newest-first and report ONLY records newer than + // the stored watermark, so a record crosses the wire exactly once. + // + // Two things changed here versus the original scaffold. First, filtering by + // event id now happens inside the event log service through an XPath query + // instead of walking every entry in the channel from C#; on a busy Security + // log that is the difference between touching a handful of records and + // hundreds of thousands. Second, EventLogReader (not the legacy EventLog + // API) exposes RecordId, which is what makes exactly-once possible. + // + // First contact on a channel has no watermark, so it sends a bounded tail + // (MaxEventsPerChannel) for initial context rather than the whole backlog. + // The same cap also bounds a burst: if more than the cap arrived since the + // last cycle, the NEWEST are taken and the watermark still jumps to the + // newest, so the payload can never balloon. private EndpointEventRecord[] GetCuratedEvents() { var records = new List(); foreach (var channel in CuratedEventCatalog.Channels) { var wanted = CuratedEventCatalog.IdsForChannel(channel); + if (wanted.Count == 0) + continue; + try { - using var log = new EventLog(channel); - int kept = 0; - for (int i = log.Entries.Count - 1; - i >= 0 && kept < MaxEventsPerChannel; i--) - { - EventLogEntry entry; - try { entry = log.Entries[i]; } catch { continue; } - - int id = (int)(entry.InstanceId & 0xFFFF); - if (!wanted.Contains(id)) - continue; - - records.Add(new EndpointEventRecord - { - EventId = id, - EventChannel = channel, - EventSource = entry.Source, - EventTime = new DateTimeOffset( - entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero), - EventMessage = Truncate(entry.Message, 1000), - }); - kept++; - } + records.AddRange(ReadChannel(channel, wanted)); } catch (Exception error) { @@ -493,9 +717,172 @@ public sealed class EndpointCollector : ICollector logger.LogDebug(error, "Event channel {Channel} unreadable", channel); } } + + watermarks?.Save(); return records.ToArray(); } + private IEnumerable ReadChannel( + string channel, IReadOnlySet wanted) + { + long watermark = watermarks?.GetWatermark(channel) ?? 0; + bool seeding = watermarks is null || !watermarks.HasWatermark(channel); + + var query = new EventLogQuery(channel, PathType.LogName, BuildIdFilter(wanted)) + { + // Newest-first so the cap keeps the most recent records and we can + // stop as soon as we reach the watermark. + ReverseDirection = true, + }; + + var found = new List(); + long newestSeen = 0; + + using var reader = new EventLogReader(query); + while (found.Count < MaxEventsPerChannel) + { + EventRecord? entry; + try { entry = reader.ReadEvent(); } + catch (Exception error) + { + logger.LogDebug(error, "Event read failed on {Channel}", channel); + break; + } + + if (entry is null) + break; + + using (entry) + { + long recordId = entry.RecordId ?? 0; + if (recordId > newestSeen) + newestSeen = recordId; + + // Reverse order means everything from here down is older than + // the watermark and has already been reported. + if (!seeding && recordId != 0 && recordId <= watermark) + break; + + found.Add(ToEventRecord(entry, channel, recordId)); + } + } + + // A cleared log restarts record ids low; reseed rather than go silent. + if (!seeding && newestSeen > 0 + && watermarks is not null && watermarks.ResetIfStale(channel, newestSeen)) + { + return found; + } + + if (newestSeen > 0) + watermarks?.Advance(channel, newestSeen); + + return found; + } + + private EndpointEventRecord ToEventRecord( + EventRecord entry, string channel, long recordId) + { + string? message = null; + try { message = entry.FormatDescription(); } + catch (Exception error) + { + // A provider whose message DLL is missing cannot render text; the + // id, time, and source are still worth reporting. + logger.LogDebug(error, "Event message not renderable on {Channel}", channel); + } + + return new EndpointEventRecord + { + EventId = entry.Id, + EventRecordId = recordId == 0 ? null : recordId, + EventChannel = channel, + EventSource = entry.ProviderName, + EventTime = entry.TimeCreated is DateTime created + ? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero) + : null, + EventMessage = Truncate(message, 1000), + Fields = GetEventFields(entry, channel), + }; + } + + // Bounds on the structured field extraction. The rendered message is often + // a paragraph of prose while the useful specifics (which account, which + // logon type, which faulting module) live in EventData, so it is worth + // carrying - but a single record can declare a lot of elements and some + // providers emit large blobs, so both the count and each value are capped. + private const int MaxEventFields = 30; + private const int MaxEventFieldLength = 512; + + // Pull the provider's structured payload out of the event XML into the + // existing `fields` dictionary, which the server already stores as + // payloadjson. Most providers use v; + // some use with an arbitrary provider-defined subtree, so both + // are walked. Unnamed elements keep their positional index as the + // key, because for several catalog IDs position IS the meaning. + // + // Never throws: a provider with malformed or unreadable XML degrades to no + // fields rather than losing the whole record. + private Dictionary? GetEventFields(EventRecord entry, string channel) + { + try + { + var xml = entry.ToXml(); + if (string.IsNullOrWhiteSpace(xml)) + return null; + + var document = System.Xml.Linq.XDocument.Parse(xml); + var root = document.Root; + if (root is null) + return null; + + var container = + root.Elements().FirstOrDefault(e => e.Name.LocalName == "EventData") + ?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "UserData"); + if (container is null) + return null; + + var fields = new Dictionary(); + int unnamed = 0; + foreach (var element in container.Descendants()) + { + if (fields.Count >= MaxEventFields) + break; + if (element.HasElements) + continue; + + var value = element.Value; + if (string.IsNullOrWhiteSpace(value)) + continue; + + var name = element.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + name = element.Name.LocalName == "Data" + ? "data" + unnamed++ + : element.Name.LocalName; + } + + fields[name!] = Truncate(value, MaxEventFieldLength) ?? string.Empty; + } + + return fields.Count > 0 ? fields : null; + } + catch (Exception error) + { + logger.LogDebug(error, "Event XML not parseable on {Channel}", channel); + return null; + } + } + + // An XPath the event log service evaluates itself, e.g. + // *[System[(EventID=4624 or EventID=4625)]] + private static string BuildIdFilter(IReadOnlySet wanted) + { + var clauses = string.Join(" or ", wanted.Select(id => "EventID=" + id)); + return "*[System[(" + clauses + ")]]"; + } + // Installed-software SBOM. Mirrors the Assess-Orchestrator applications // collection exactly: enumerate both Uninstall registry hives (64-bit and // WOW6432Node), keep only entries with a DisplayName, and emit @@ -566,6 +953,243 @@ public sealed class EndpointCollector : ICollector } } + // Whether Windows is waiting on a reboot. + // + // There is no single API for this, so we check the three signals Windows + // itself uses: the CBS servicing flag, the Windows Update auto-update flag, + // and a queued file-rename operation. Any one of them is sufficient. Checked + // by key EXISTENCE rather than value, which is how these flags work - the + // key is created when a reboot is needed and deleted once it happens. + // Link speed and duplex, keyed by MAC so the caller can pair them onto the + // matching GetInterfaces() entry. Deliberately a separate query: the + // IPEnabled config class above does not carry link state, and a NIC that + // negotiated 100Mb half-duplex on a gigabit switch port is a cable/port + // fault that otherwise presents as "the machine is slow sometimes". + private Dictionary GetLinkState() + { + var byMac = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + // MSFT_NetAdapter carries duplex; Win32_NetworkAdapter does not. It + // lives in the same Storage-style optional namespace family, so a + // failure here must not take the interface list with it. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\StandardCimv2", + "SELECT PermanentAddress, Speed, FullDuplex, MediaConnectState " + + "FROM MSFT_NetAdapter"); + foreach (var mo in searcher.Get()) + { + var mac = NormalizeMac(mo["PermanentAddress"]?.ToString()); + if (mac is null) + continue; + byMac[mac] = (ToLong(mo["Speed"]), mo["FullDuplex"] as bool?); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Net adapter link state query failed"); + } + + return byMac; + } + + // WMI reports MACs in several shapes (00:11:22, 001122AABBCC). Strip to hex + // so the two sources can be matched at all. + private static string? NormalizeMac(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + var hex = new string(Array.FindAll(raw.ToCharArray(), Uri.IsHexDigit)); + return hex.Length == 12 ? hex : null; + } + + // Whether the last start was a true cold boot or a fast-startup resume. + // + // This matters because uptime LIES under fast startup: the box reports days + // of uptime after what the operator experienced as a shutdown, and a + // "reboot" that was really a hybrid resume does not clear the driver or + // kernel state that a support call is usually trying to clear. + // + // Two distinct facts, deliberately not collapsed: + // hiberbootenabled - fast startup is CONFIGURED (registry, always readable) + // lastboottype - what the last boot ACTUALLY did (Kernel-Boot event 27) + // The registry alone cannot tell you what the last boot did, and the event + // may have aged out of the log, so neither substitutes for the other. + private object? GetBootInfo() + { + bool? hiberboot = null; + try + { + using var power = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager\Power"); + if (power?.GetValue("HiberbootEnabled") is int raw) + hiberboot = raw != 0; + } + catch (Exception error) + { + logger.LogDebug(error, "Hiberboot registry read failed"); + } + + string? lastboottype = null; + try + { + // Kernel-Boot 27 BootType: 0 cold, 1 hybrid (fast startup), + // 2 resume from hibernate. + var query = new EventLogQuery( + "System", PathType.LogName, + "*[System[Provider[@Name='Microsoft-Windows-Kernel-Boot'] " + + "and (EventID=27)]]") + { ReverseDirection = true }; + using var reader = new EventLogReader(query); + using var record = reader.ReadEvent(); + if (record?.Properties.Count > 0) + { + lastboottype = ToInt(record.Properties[0].Value) switch + { + 0 => "Cold", + 1 => "Fast startup", + 2 => "Resume", + _ => null, + }; + } + } + catch (Exception error) + { + logger.LogDebug(error, "Boot type event read failed"); + } + + if (hiberboot is null && lastboottype is null) + return null; + + return new + { + hiberbootenabled = hiberboot, + lastboottype = lastboottype ?? "Unknown", + }; + } + + // Defender status. Collected and persisted but NOT surfaced: this + // environment is known to produce false positives, so wiring it to a card + // or an alert now would train operators to ignore the card. Stored so the + // signal is already there when someone is ready to interpret it. + private object? GetDefender() + { + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Defender", + "SELECT AMServiceEnabled, RealTimeProtectionEnabled, " + + "AntivirusSignatureAge, AntispywareSignatureAge, " + + "QuickScanAge FROM MSFT_MpComputerStatus"); + foreach (var mo in searcher.Get()) + { + return new + { + serviceenabled = mo["AMServiceEnabled"] as bool?, + realtimeenabled = mo["RealTimeProtectionEnabled"] as bool?, + // Ages in days. Null when Defender did not report rather + // than 0, which would read as "updated today". + signatureagedays = ToLong(mo["AntivirusSignatureAge"]), + antispywareagedays = ToLong(mo["AntispywareSignatureAge"]), + quickscanagedays = ToLong(mo["QuickScanAge"]), + }; + } + } + catch (Exception error) + { + // A third-party AV box or a stripped image has no Defender WMI + // provider at all. That is normal here, not a fault. + logger.LogDebug(error, "Defender status query failed"); + } + return null; + } + + // Which signal(s) drove pendingreboot=true, in report order. + // + // pendingreboot is a single bool OR-ed from three sources, so it cannot say + // WHY. That distinction is the point: PendingFileRenameOperations is the + // stronger signal - files are staged to be replaced on next boot, so the + // box is mid-servicing - whereas a lone CBS or Windows Update key can sit + // set for weeks on a machine nobody has rebooted. Same boolean, very + // different urgency. + // + // Empty array = checked, nothing pending. Null = the check failed, matching + // pendingreboot's own null-is-unknown rule. + private string[]? GetRebootReasons() + { + try + { + var reasons = new List(); + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending")) + { + reasons.Add("componentbasedservicing"); + } + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired")) + { + reasons.Add("windowsupdate"); + } + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + reasons.Add("pendingfilerename"); + } + + return reasons.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Reboot reason check failed"); + return null; + } + } + + private bool? GetPendingReboot() + { + try + { + const string cbs = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"; + const string windowsUpdate = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"; + + if (LocalMachineKeyExists(cbs) || LocalMachineKeyExists(windowsUpdate)) + return true; + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + return true; + } + + return false; + } + catch (Exception error) + { + // Null, not false: an unreadable registry means we do not know, and + // reporting a confident "no reboot pending" would be worse than + // reporting nothing. + logger.LogDebug(error, "Pending reboot check failed"); + return null; + } + } + + private static bool LocalMachineKeyExists(string path) + { + using var key = Registry.LocalMachine.OpenSubKey(path); + return key is not null; + } + // Fixed local disks with capacity/free so the server can flag a PC that is // running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk; // network/removable drives are skipped. Bytes stay as longs; the server can @@ -591,6 +1215,14 @@ public sealed class EndpointCollector : ICollector freebytes = free, usedbytes = size is long total && free is long available ? total - available : (long?)null, + // Precomputed so an alert threshold is a direct comparison. + // A 40GB free reading means nothing without the capacity it + // came from, and the server would have to guard against a + // null or zero size on every evaluation to derive it. + freepercent = size is long capacity && capacity > 0 + && free is long remaining + ? Math.Round((double)remaining / capacity * 100.0, 1) + : (double?)null, }); } } @@ -601,6 +1233,129 @@ public sealed class EndpointCollector : ICollector return result.ToArray(); } + // Physical drive health, so a failing disk is caught before it takes the + // machine and whatever was local on it. + // + // Two sources, because neither covers the fleet alone: + // + // MSFT_PhysicalDisk (Storage namespace) is the only one that reports NVMe. + // The classic SMART class, MSStorageDriver_FailurePredictStatus, is ATA/SATA + // only and returns NOTHING on an NVMe box - verified on a two-NVMe machine + // here, where it yielded zero instances while the drives were present and + // healthy. Collecting only that class would have reported an empty disk + // health list on every modern endpoint and looked like "no problems". + // + // Win32_DiskDrive.Status is the old reliable string ("OK"/"Pred Fail") and + // covers anything the Storage namespace does not, including machines where + // that namespace is missing or blocked. + // + // Keyed by DeviceId so the server can pair the two views per drive the same + // way mapped drives are paired. + private object[] GetDiskHealth() + { + var result = new List(); + + // MSFT_PhysicalDisk returns raw enum INTEGERS through + // ManagementObjectSearcher, not the friendly strings Get-PhysicalDisk + // prints. Verified on a live box: Health=0 Op=2 for a healthy NVMe. + // Mapping them here keeps the wire self-describing; shipping bare + // integers would make the server guess at an undocumented enum. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Storage", + "SELECT DeviceId, FriendlyName, MediaType, BusType, " + + "HealthStatus, OperationalStatus FROM MSFT_PhysicalDisk"); + foreach (var mo in searcher.Get()) + { + var health = ToInt(mo["HealthStatus"]); + result.Add(new + { + deviceid = mo["DeviceId"]?.ToString()?.Trim() ?? "", + model = mo["FriendlyName"]?.ToString()?.Trim() ?? "", + mediatype = PhysicalDiskMediaType(ToInt(mo["MediaType"])), + bustype = PhysicalDiskBusType(ToInt(mo["BusType"])), + healthstatus = PhysicalDiskHealth(health), + // The actionable bit, precomputed so an alert is a direct + // boolean test rather than a string comparison against an + // enum the server would have to keep in sync with this file. + // Null when health is unreadable: unknown is not healthy, + // but it is not a confident failure either. + predictfailure = health is null ? (bool?)null : health != 0, + source = "physicaldisk", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Physical disk health query failed"); + } + + // Win32_DiskDrive as the ATA/SATA and fallback view. Status is a plain + // string here, no enum mapping needed. + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT DeviceID, Model, Status FROM Win32_DiskDrive"); + foreach (var mo in searcher.Get()) + { + var status = mo["Status"]?.ToString()?.Trim() ?? ""; + result.Add(new + { + deviceid = mo["DeviceID"]?.ToString()?.Trim() ?? "", + model = mo["Model"]?.ToString()?.Trim() ?? "", + mediatype = "", + bustype = "", + healthstatus = status, + // "OK" is the only healthy value this class reports; + // "Pred Fail" is the SMART trip. An empty status means the + // drive did not answer, which is unknown rather than failed. + predictfailure = status.Length == 0 + ? (bool?)null + : !status.Equals("OK", StringComparison.OrdinalIgnoreCase), + source = "diskdrive", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Disk drive status query failed"); + } + + return result.ToArray(); + } + + // MSFT_PhysicalDisk HealthStatus: 0 Healthy, 1 Warning, 2 Unhealthy. + private static string PhysicalDiskHealth(int? value) => value switch + { + 0 => "Healthy", + 1 => "Warning", + 2 => "Unhealthy", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk MediaType: 3 HDD, 4 SSD, 5 SCM. + private static string PhysicalDiskMediaType(int? value) => value switch + { + 3 => "HDD", + 4 => "SSD", + 5 => "SCM", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk BusType. Only the values that actually appear on an + // endpoint are named; anything else stays numeric rather than guessed at. + private static string PhysicalDiskBusType(int? value) => value switch + { + 3 => "ATA", + 7 => "USB", + 8 => "RAID", + 9 => "iSCSI", + 11 => "SATA", + 17 => "NVMe", + _ => value is null ? "Unknown" : value.Value.ToString(), + }; + // Firmware identity from Win32_BIOS: vendor, version string, and release // date. Lets the server track BIOS/UEFI levels for a fleet update campaign. private object? GetBios() @@ -737,6 +1492,14 @@ public sealed class EndpointCollector : ICollector catch { return null; } } + private static int? ToInt(object? value) + { + if (value is null) + return null; + try { return Convert.ToInt32(value); } + catch { return null; } + } + // Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC // string; anything unparseable passes through untouched so nothing is lost. private string? ToBiosDate(string? raw) diff --git a/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs b/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs new file mode 100644 index 00000000..ba4cfdd4 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Remembers the newest Windows event record id already reported for each +// curated channel, so a collect cycle harvests ONLY records the server has not +// seen. +// +// Why this exists: the collector used to re-read the newest N records of every +// channel on every cycle, and the server ingest appends event rows without a +// dedupe check. A single crash therefore landed in the database once per +// cadence tick for as long as it stayed inside that window - up to 96 identical +// rows a day per event. Carrying a watermark makes the steady state "only what +// is new", which is also what makes the full event XML affordable to send. +// +// The watermark is persisted under %ProgramData% (not in memory) because a +// service restart would otherwise re-report the whole window again, which is +// exactly the crash-loop case where duplicate events are most likely. +// +// Record ids are per channel and monotonic on a given machine. A cleared or +// rebuilt event log restarts them at 1; that is handled by treating a stored +// watermark HIGHER than the newest available record as stale and resetting it, +// otherwise the channel would go permanently silent. +public sealed class EventWatermarkStore +{ + private const string StoreFileName = "eventwatermark.json"; + + // %ProgramData%\ShopDB\EndpointAgent - the same root the rolling agent log + // lives under, so all agent-owned state sits in one place. + public static string DefaultStateDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "EndpointAgent"); + + private readonly string storePath; + private readonly ILogger logger; + private readonly Dictionary watermarks; + + public EventWatermarkStore(ILogger logger, + string? stateDirectory = null) + { + this.logger = logger; + var directory = string.IsNullOrWhiteSpace(stateDirectory) + ? DefaultStateDirectory + : stateDirectory; + storePath = Path.Combine(directory, StoreFileName); + watermarks = Load(); + } + + // The last record id reported for this channel, or 0 when the channel has + // never been harvested. 0 means "first contact": the caller sends a bounded + // tail for initial context rather than the entire backlog. + public long GetWatermark(string channel) + => watermarks.TryGetValue(channel, out var value) ? value : 0; + + public bool HasWatermark(string channel) + => watermarks.ContainsKey(channel); + + // Advance the channel forward only. Never moves backwards, so an + // out-of-order read cannot cause the same record to be reported twice. + public void Advance(string channel, long recordId) + { + if (recordId <= 0) + return; + if (watermarks.TryGetValue(channel, out var current) && current >= recordId) + return; + watermarks[channel] = recordId; + } + + // A cleared or rebuilt log restarts record ids near 1. When the newest + // record available is below the stored watermark the store is stale, so + // drop it and let the channel re-seed instead of going silent forever. + public bool ResetIfStale(string channel, long newestAvailableRecordId) + { + if (newestAvailableRecordId <= 0) + return false; + if (!watermarks.TryGetValue(channel, out var current) || current <= newestAvailableRecordId) + return false; + + logger.LogInformation( + "Event channel {Channel} record id went backwards ({Stored} -> {Newest}); log was cleared, reseeding", + channel, current, newestAvailableRecordId); + watermarks.Remove(channel); + return true; + } + + // Best-effort persist. A failure here costs at most a repeated report of + // records already sent, so it must never abort a collection. + public void Save() + { + try + { + var directory = Path.GetDirectoryName(storePath); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + // Write-then-rename so a crash mid-write cannot leave a truncated + // file that would reseed every channel on the next start. + var temporaryPath = storePath + ".tmp"; + File.WriteAllText(temporaryPath, + JsonSerializer.Serialize(watermarks)); + File.Move(temporaryPath, storePath, overwrite: true); + } + catch (Exception error) + { + logger.LogDebug(error, "Could not persist event watermarks to {Path}", storePath); + } + } + + private Dictionary Load() + { + try + { + if (!File.Exists(storePath)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var loaded = JsonSerializer.Deserialize>( + File.ReadAllText(storePath)); + return loaded is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(loaded, StringComparer.OrdinalIgnoreCase); + } + catch (Exception error) + { + // A corrupt store reseeds every channel, which costs one bounded + // tail per channel - acceptable versus failing the collection. + logger.LogWarning(error, + "Event watermark store unreadable at {Path}; reseeding channels", storePath); + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs index fa374105..7ff794ed 100644 --- a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -14,32 +14,74 @@ public sealed class FileLoggerProvider : ILoggerProvider private readonly string directory; private readonly LogLevel minLevel; private readonly int retentionDays; + private readonly string filePrefix; private readonly object gate = new(); private readonly ConcurrentDictionary loggers = new(); private string currentDateStamp = ""; private StreamWriter? writer; + // Why the sink can be unusable, or null when it opened normally. The + // constructor cannot throw (see below), but a caller that has a second, + // known-writable place to complain to needs to be able to ask. + public string? SinkProblem { get; private set; } + public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information, - int retentionDays = 14) + int retentionDays = 14, string filePrefix = "agent") { this.directory = directory; this.minLevel = minLevel; this.retentionDays = retentionDays; + this.filePrefix = filePrefix; // Best-effort: creating the log directory can fail on a locked-down host // or when a non-privileged user targets a SYSTEM-owned path. Never let // that abort construction; Write() degrades to a no-op if the sink is // unusable. + // + // The reason is KEPT rather than discarded. A silently dead sink is what + // made the user-context probe undiagnosable: running as the operator it + // could not write into the SYSTEM-owned ProgramData log directory, so a + // probe that failed and a probe that never ran produced identical + // silence. Callers that have a writable fallback can now surface this. try { Directory.CreateDirectory(directory); } - catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + SinkProblem = $"{directory}: {error.Message}"; + } } // The default log directory when config leaves LogPath empty: // %ProgramData%\ShopDB\EndpointAgent\logs + // + // SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot + // write to it, so anything running in the user's session must use + // UserProfileLogDirectory() instead. public static string DefaultDirectory() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "ShopDB", "EndpointAgent", "logs"); + // The shop-floor operator account. Every shop-floor endpoint uses this same + // local account name, so the path is fixed rather than resolved through the + // interactive token: the token lookup is the part that fails on a hardened + // endpoint, and when it fails the writer and the reader silently disagree + // about where the spool lives. If a site ever uses a different account only + // user-context is lost - telemetry, commands and inventory are unaffected. + public const string ShopFloorUserName = "ShopFloor"; + + public static string ShopFloorProfileDirectory() + => Path.Combine(DrivesRoot, "Users", ShopFloorUserName); + + private static string DrivesRoot => + Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows)) + ?? @"C:\"; + + // Where the TRAY and the user-context probe log: + // C:\Users\ShopFloor\ShopDB\Logs + // The operator owns this directory, so it is writable from the user session + // where ProgramData is not. + public static string UserProfileLogDirectory() + => Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs"); + public ILogger CreateLogger(string categoryName) => loggers.GetOrAdd(categoryName, name => new FileLogger(this, name)); @@ -85,7 +127,14 @@ public sealed class FileLoggerProvider : ILoggerProvider writer?.Flush(); writer?.Dispose(); currentDateStamp = stamp; - var path = Path.Combine(directory, $"agent-{stamp}.log"); + // One convention for every log this codebase writes: + // "-.log", so agent, tray and installer logs sort + // chronologically when they share a directory and can be collected with + // a single date-leading glob. The service previously used the opposite + // order, which meant a support request for "the logs from the 5th" had to + // know which component wrote which shape. + var name = $"{stamp}-{filePrefix}.log"; + var path = Path.Combine(directory, name); writer = new StreamWriter(new FileStream( path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) { @@ -99,7 +148,12 @@ public sealed class FileLoggerProvider : ILoggerProvider try { var cutoff = DateTime.Now.AddDays(-retentionDays); - foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log")) + // Both shapes: retention has to keep covering the "-" + // files written before the convention was unified, or an upgraded + // endpoint would keep its pre-upgrade logs forever. + string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"]; + foreach (var pattern in patterns) + foreach (var file in Directory.EnumerateFiles(directory, pattern)) { if (File.GetLastWriteTime(file) < cutoff) File.Delete(file); diff --git a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs index 326bdc07..b9c45755 100644 --- a/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs +++ b/agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Runtime.InteropServices; +using System.Text; namespace ShopDbEndpointAgent.Core; @@ -18,7 +19,11 @@ namespace ShopDbEndpointAgent.Core; // Only process_start uses this. Stop/status/service actions stay as SYSTEM. public static class InteractiveProcessLauncher { - public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null); + // TimedOut=true means the process launched fine but was still running when + // the caller's wait budget expired. It is deliberately NOT killed, so the + // work still completes; the caller just stops blocking on it. + public readonly record struct LaunchOutcome( + bool Launched, string? Error, int? ExitCode = null, bool TimedOut = false); private const int TokenDuplicate = 0x0002; private const int TokenQuery = 0x0008; @@ -34,6 +39,10 @@ public static class InteractiveProcessLauncher private const uint Infinite = 0xFFFFFFFF; + // WaitForSingleObject returns this when the budget expires with the handle + // still unsignalled. + private const uint WaitTimeout = 0x00000102; + // Returns Launched=true when CreateProcessAsUser succeeds. Never throws; // failures come back as Launched=false with a descriptive Error so the // caller can surface a clean CommandResult. @@ -51,9 +60,17 @@ public static class InteractiveProcessLauncher // needs the actual outcome (run_script in the user session) can report // completed/failed instead of fire-and-forget "launched". GUI callers // (process_start) leave it false and return as soon as the window is up. + // + // waitTimeout bounds that wait. This matters because the caller may be the + // telemetry loop, and telemetry IS the heartbeat: a script that hangs in the + // user session used to block here forever (the wait was hardcoded to + // Infinite), which would stall reporting and make a perfectly healthy PC + // read as offline in the WebUI. On expiry the child is left running and the + // outcome comes back with TimedOut=true. Null means wait forever and should + // only be used by a caller that is not on a timed path. public static LaunchOutcome LaunchInActiveSession( string applicationPath, string? arguments, string? workingDirectory, - bool waitForExit = false) + bool waitForExit = false, TimeSpan? waitTimeout = null) { var sessionId = WTSGetActiveConsoleSessionId(); if (sessionId == InvalidSessionId) @@ -129,11 +146,25 @@ public static class InteractiveProcessLauncher } int? exitCode = null; + bool timedOut = false; if (waitForExit && processInformation.hProcess != IntPtr.Zero) { - WaitForSingleObject(processInformation.hProcess, Infinite); - if (GetExitCodeProcess(processInformation.hProcess, out var code)) + uint milliseconds = waitTimeout is TimeSpan budget + ? (uint)Math.Clamp(budget.TotalMilliseconds, 0, uint.MaxValue - 1) + : Infinite; + + if (WaitForSingleObject(processInformation.hProcess, milliseconds) + == WaitTimeout) + { + // Still running. Leave it alone - killing it would abandon + // half-done work, and a file-spooling probe will still write + // its result for the next cycle to pick up. + timedOut = true; + } + else if (GetExitCodeProcess(processInformation.hProcess, out var code)) + { exitCode = unchecked((int)code); + } } if (processInformation.hProcess != IntPtr.Zero) @@ -141,7 +172,7 @@ public static class InteractiveProcessLauncher if (processInformation.hThread != IntPtr.Zero) CloseHandle(processInformation.hThread); - return new LaunchOutcome(true, null, exitCode); + return new LaunchOutcome(true, null, exitCode, timedOut); } finally { @@ -154,6 +185,57 @@ public static class InteractiveProcessLauncher } } + // Profile directory of the active console user, for example + // C:\Users\ShopFloor. The agent runs as LocalSystem, so expanding + // %USERPROFILE% here would return SYSTEM's own profile under + // config\systemprofile rather than the operator's. The interactive user's + // token is the only reliable source, and this class already knows how to + // obtain it. Returns null with a reason when nobody is signed in. + public static string? TryGetActiveUserProfileDirectory(out string? error) + { + var sessionId = WTSGetActiveConsoleSessionId(); + if (sessionId == InvalidSessionId) + { + error = "No active console session (no interactive user is signed in)."; + return null; + } + + if (!WTSQueryUserToken(sessionId, out var userToken)) + { + error = "Could not obtain the interactive user token: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + try + { + uint size = 0; + GetUserProfileDirectory(userToken, null, ref size); + if (size == 0) + { + error = "Could not size the user profile directory: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + var buffer = new StringBuilder((int)size); + if (!GetUserProfileDirectory(userToken, buffer, ref size)) + { + error = "Could not resolve the user profile directory: " + + new Win32Exception(Marshal.GetLastWin32Error()).Message; + return null; + } + + error = null; + return buffer.ToString(); + } + finally + { + if (userToken != IntPtr.Zero) + CloseHandle(userToken); + } + } + private enum SecurityImpersonationLevel { SecurityAnonymous, @@ -222,6 +304,10 @@ public static class InteractiveProcessLauncher [DllImport("userenv.dll", SetLastError = true)] private static extern bool DestroyEnvironmentBlock(IntPtr environment); + [DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool GetUserProfileDirectory( + IntPtr token, StringBuilder? profileDirectory, ref uint size); + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CreateProcessAsUser( IntPtr token, diff --git a/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs b/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs new file mode 100644 index 00000000..0a328267 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs @@ -0,0 +1,159 @@ +namespace ShopDbEndpointAgent.Core; + +// Guards which registered scheduled tasks the scheduledtask_run action may +// start. The wire carries a free-text task path (so the WebUI can expose it as +// one field, and a site can widen the set without an agent rebuild); this +// policy is the safeguard that makes that free text safe to honor. +// +// WHY THIS EXISTS AT ALL. The agent runs as SYSTEM. A scheduled task is an +// INDIRECTION: the task path names a definition, and that definition chooses +// what actually runs. So "start the task named X" is only as safe as the set of +// X the agent will accept - an unconstrained task name would be a general- +// purpose "run anything already registered on this box, as whatever context it +// asked for" primitive. That is a materially bigger grant than any other +// allowlisted action, and it would be invisible in the audit trail (the command +// log would read "scheduledtask_run \Foo" no matter what \Foo does). +// +// WHAT THIS DOES AND DOES NOT BUY. A prefix allowlist stops a compromised or +// misconfigured SERVER from starting arbitrary tasks on the fleet - that is the +// threat it actually addresses, and it is the realistic one. It does NOT stop an +// attacker who can already register or rewrite task definitions on the endpoint: +// such an attacker can define a task under an approved prefix and have the agent +// start it. That is accepted, because anyone who can write task definitions on a +// box already has the privilege the task would grant them. Local task definition +// ACLs, not this policy, are the control for that. +// +// The approved-prefix list ships with the Corporate Tools set and is overridable +// per site via AgentConfig.ApprovedTaskPaths, matching the LaunchPathPolicy +// pattern so the future Site Agent Configuration tool can narrow or widen it +// without a rebuild. +public sealed class ScheduledTaskPolicy +{ + // Shipped defaults: the Corporate Tools task set. Prefix entries end with a + // backslash and match a whole folder; entries without one match that exact + // task path only. Kept deliberately tight - widen per site, not here. + // + // The ShopDB-* entries are tasks ShopDB itself owns. They are separate task + // definitions from any corporate-initiated equivalent ON PURPOSE: a + // ShopDB-initiated Dell Command scan must write its results somewhere the + // agent's collector can attribute to ShopDB, rather than into whatever + // location a corporate-managed task already writes. Keeping the definitions + // distinct is what makes "who asked for this scan" answerable at all - see + // the note in commands.py about the DCU output path convention. + public static IReadOnlyList DefaultApprovedTaskPaths { get; } = new[] + { + @"\ReportIP", + @"\SFLD-ApplyDSCConfig", + @"\SFLD - Consume Credentials", + @"\ShopDB-DellCommandScan", + @"\ShopDB-DellCommandUpdate", + }; + + private readonly IReadOnlyList approvedPaths; + + public ScheduledTaskPolicy(IEnumerable? approvedPathsOverride = null) + { + var paths = (approvedPathsOverride ?? Enumerable.Empty()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeTaskPath) + .ToList(); + approvedPaths = paths.Count > 0 ? paths : DefaultApprovedTaskPaths; + } + + // Validates a candidate task path. On success taskPath is the normalized + // path to hand to Task Scheduler; on failure reason explains the rejection + // so the command result tells the operator WHY rather than just "denied". + public bool TryValidate(string? candidate, out string taskPath, out string reason) + { + taskPath = ""; + reason = ""; + + if (string.IsNullOrWhiteSpace(candidate)) + { + reason = "empty task path"; + return false; + } + + var normalized = NormalizeTaskPath(candidate); + + // Traversal in a task path is meaningless to Task Scheduler but very + // meaningful to a prefix check: "\Approved\..\Evil" starts with an + // approved prefix as a STRING while naming something else entirely. + // Reject rather than try to canonicalize it away. + if (normalized.Contains("..")) + { + reason = "task path contains a traversal segment"; + return false; + } + + // Control characters and quotes have no place in a task path and are the + // shape of an attempt to break out of the argument. The invocation uses + // ArgumentList (no shell, no string concatenation) so this is defense in + // depth rather than the primary control, but a task path that needs + // these is not a task path we should be starting. + foreach (var ch in normalized) + { + if (char.IsControl(ch) || ch == '"' || ch == '&' || ch == '|') + { + reason = "task path contains an illegal character"; + return false; + } + } + + if (!IsApproved(normalized)) + { + reason = "task path is not in the approved task list"; + return false; + } + + taskPath = normalized; + return true; + } + + // Task Scheduler paths are rooted and backslash-separated. Accept the + // operator writing "ReportIP", "\ReportIP", or "/ReportIP" and settle on the + // rooted form, so an allowlist entry does not hinge on how it was typed. + private static string NormalizeTaskPath(string path) + { + var trimmed = (path ?? "").Trim().Replace('/', '\\'); + if (trimmed.Length == 0) + return ""; + if (!trimmed.StartsWith('\\')) + trimmed = "\\" + trimmed; + // Collapse a trailing separator so "\Folder\" and "\Folder" compare + // equal as exact entries; prefix semantics are handled in IsApproved. + while (trimmed.Length > 1 && trimmed.EndsWith('\\')) + trimmed = trimmed[..^1]; + return trimmed; + } + + // An entry ending in a separator is a FOLDER prefix and matches anything + // beneath it; any other entry must match the whole task path exactly. The + // exact-match case is why "\Approved" does not silently authorize + // "\ApprovedEvil". + private bool IsApproved(string candidate) + { + foreach (var entry in approvedPaths) + { + if (entry.Length == 0) + continue; + + var isFolderPrefix = entry.EndsWith('\\'); + if (isFolderPrefix) + { + if (candidate.StartsWith(entry, StringComparison.OrdinalIgnoreCase)) + return true; + continue; + } + + if (string.Equals(candidate, entry, StringComparison.OrdinalIgnoreCase)) + return true; + + // Allow the folder form without its trailing separator to still act + // as a folder: "\SFLD" approves "\SFLD\Thing" but never "\SFLDEvil". + if (candidate.StartsWith(entry + "\\", StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj index 013080b0..ee711845 100644 --- a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj +++ b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj @@ -17,6 +17,10 @@ + + diff --git a/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs b/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs new file mode 100644 index 00000000..26af6257 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/TcpTableReader.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Runtime.InteropServices; + +namespace ShopDbEndpointAgent.Core; + +// TCP connection state with owning process ids, via GetExtendedTcpTable. +// +// Why P/Invoke instead of shelling out to netstat.exe: parsing netstat text +// output means spawning a process on every telemetry tick and depending on the +// column layout of a localized command. GetExtendedTcpTable is the API netstat +// itself calls, returns process ids directly, and costs a single allocation. +// +// Listeners are the cheap, bounded half of this (a handful of rows, stable over +// time) and ride the normal cadence. The full connection table is unbounded and +// churns constantly, so it stays on-demand. +public static class TcpTableReader +{ + private const int AF_INET = 2; + private const uint ERROR_INSUFFICIENT_BUFFER = 122; + private const int TCP_TABLE_OWNER_PID_ALL = 5; + + public enum TcpState + { + Closed = 1, + Listen = 2, + SynSent = 3, + SynReceived = 4, + Established = 5, + FinWait1 = 6, + FinWait2 = 7, + CloseWait = 8, + Closing = 9, + LastAck = 10, + TimeWait = 11, + DeleteTcb = 12, + } + + public readonly record struct TcpRow( + string LocalAddress, + int LocalPort, + string RemoteAddress, + int RemotePort, + TcpState State, + int ProcessId); + + // Full IPv4 TCP table. Throws only on catastrophic interop failure; callers + // treat an empty list as "not observed". + public static List GetTcpTable() + { + var rows = new List(); + var buffer = IntPtr.Zero; + var size = 0; + + try + { + // First call sizes the buffer. It is expected to fail with + // INSUFFICIENT_BUFFER; anything else is a real error. + var result = GetExtendedTcpTable( + IntPtr.Zero, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + if (result != ERROR_INSUFFICIENT_BUFFER && result != 0) + return rows; + + buffer = Marshal.AllocHGlobal(size); + result = GetExtendedTcpTable( + buffer, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); + if (result != 0) + return rows; + + var entryCount = Marshal.ReadInt32(buffer); + var rowSize = Marshal.SizeOf(); + var cursor = IntPtr.Add(buffer, 4); + + for (var index = 0; index < entryCount; index++) + { + var row = Marshal.PtrToStructure(cursor); + rows.Add(new TcpRow( + FormatAddress(row.localAddress), + FormatPort(row.localPort), + FormatAddress(row.remoteAddress), + FormatPort(row.remotePort), + (TcpState)row.state, + (int)row.owningProcessId)); + cursor = IntPtr.Add(cursor, rowSize); + } + } + finally + { + if (buffer != IntPtr.Zero) + Marshal.FreeHGlobal(buffer); + } + + return rows; + } + + public static List GetListeners() + => GetTcpTable().FindAll(row => row.State == TcpState.Listen); + + private static string FormatAddress(uint address) + => new IPAddress(BitConverter.GetBytes(address)).ToString(); + + // The port arrives as a big-endian value packed into the low two bytes of a + // host-order uint, so the bytes must be swapped rather than simply cast. + private static int FormatPort(uint port) + { + var bytes = BitConverter.GetBytes(port); + return (bytes[0] << 8) | bytes[1]; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MIB_TCPROW_OWNER_PID + { + public uint state; + public uint localAddress; + public uint localPort; + public uint remoteAddress; + public uint remotePort; + public uint owningProcessId; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint GetExtendedTcpTable( + IntPtr tcpTable, + ref int size, + bool order, + int addressFamily, + int tableClass, + int reserved); +} diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index 2efa847d..97b94bb4 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -15,6 +15,11 @@ namespace ShopDbEndpointAgent.Core; // cadence tick rather than hammering the server. public sealed class TelemetryWorker : BackgroundService { + // The one agent utility whose output is forwarded to the server today. The + // name is the .ps1 filename without its extension, which is how + // PowerShellUtilityRunner keys its results. + public const string DellUpdatesUtilityName = "shopdb-dellupdates"; + private readonly ICollector collector; private readonly CollectorClient client; private readonly IAgentUtilityRunner? utilities; @@ -22,11 +27,31 @@ public sealed class TelemetryWorker : BackgroundService private readonly string agentVersion; private readonly ILogger logger; private readonly CollectSignal? collectSignal; + private readonly UserContextSpool? userContextSpool; + private readonly UserContextRefreshChannel? userContextRefresh; private readonly Random jitter = new(); + // How long to wait for the tray to answer a refresh request. + // + // Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a + // report that arrives with slightly stale mapped drives is a working + // endpoint, while a report that never arrives because we blocked on an + // absent tray is an endpoint that looks dead. The probe itself is a single + // short PowerShell run, so a tray that is alive answers well inside this; + // anything longer means nobody is home. + private static readonly TimeSpan UserContextRefreshTimeout = TimeSpan.FromSeconds(12); + + // Poll interval while waiting for the stamp to advance. There is no + // completion signal coming back the other way - the tray's answer IS the + // rewritten spool file - so this is a poll by design, kept coarse enough to + // be free and fine enough not to add visible latency. + private static readonly TimeSpan UserContextPollInterval = TimeSpan.FromMilliseconds(400); + public TelemetryWorker(ICollector collector, CollectorClient client, AgentConfig config, string agentVersion, ILogger logger, - IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null) + IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null, + UserContextSpool? userContextSpool = null, + UserContextRefreshChannel? userContextRefresh = null) { this.collector = collector; this.client = client; @@ -35,6 +60,77 @@ public sealed class TelemetryWorker : BackgroundService this.logger = logger; this.utilities = utilities; this.collectSignal = collectSignal; + this.userContextSpool = userContextSpool; + this.userContextRefresh = userContextRefresh; + } + + // Request a tray-side re-probe and wait, briefly, for the spool to be + // rewritten. Never throws and never fails the run. + // + // The service does NOT collect session data itself and must not start doing + // so: mapped drives belong to the operator's logon token, and a LocalSystem + // probe reads a different, near-empty drive table. See the tombstone in + // Program.cs. This method is the entire service-side involvement - ask, then + // wait for the answer to appear on disk. + private async Task RefreshUserContextAsync(CancellationToken token) + { + if (userContextSpool is null || userContextRefresh is null + || !OperatingSystem.IsWindows()) + { + return; + } + + var resultPath = userContextSpool.TryGetResultPath(out _); + if (resultPath is null) + return; + + // Captured BEFORE the request so "advanced" is measured against what we + // would otherwise have reported. A missing spool reads as null, and any + // stamp at all then counts as progress - that is the first-probe case. + var before = userContextSpool.TryReadCollectedAt(resultPath); + + if (!userContextRefresh.RequestRefresh()) + { + // Could not signal at all. Note that the inverse does NOT mean a tray + // is listening: the channel opens-or-creates and does not hold the + // handle, so signalling into an empty machine succeeds by creating + // the event and finding nobody waiting on it. Detecting an absent + // tray is the timeout's job below, not this return value's. + logger.LogDebug( + "Could not request a user-context refresh; reporting the existing spool."); + return; + } + + var deadline = DateTime.UtcNow + UserContextRefreshTimeout; + while (DateTime.UtcNow < deadline) + { + try + { + await Task.Delay(UserContextPollInterval, token); + } + catch (OperationCanceledException) + { + return; + } + + var after = userContextSpool.TryReadCollectedAt(resultPath); + if (after is not null && (before is null || after > before)) + { + logger.LogDebug( + "User-context spool refreshed by the tray (collectedat={Stamp}).", + after); + return; + } + } + + // Timed out. Proceed anyway - deliberately. Session data is one card on + // one page; telemetry carries heartbeat, inventory, events and command + // results, and holding all of that hostage to a tray that did not answer + // in twelve seconds trades a small inaccuracy for a total outage. + logger.LogDebug( + "The tray did not refresh the user-context spool within {Timeout}; " + + "reporting with the existing session data.", + UserContextRefreshTimeout); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -111,6 +207,15 @@ public sealed class TelemetryWorker : BackgroundService config.Monitors.Processes.Length, config.Monitors.Services.Length); } + // Ask the tray to re-probe session data and WAIT for it, before + // collecting. Ordering is the whole point: the collector reads the spool + // file, so a refresh that lands after Collect() is a refresh the server + // does not see until the NEXT cycle. That was the bug - "Send Status" + // and "Refresh Status" both re-sent a spool nobody had rewritten, so a + // drive the operator had just unmapped kept reporting OK for up to half + // an hour and no amount of clicking changed it. + await RefreshUserContextAsync(token); + var report = collector.Collect(agentVersion, adopt); if (utilities is not null) @@ -118,6 +223,17 @@ public sealed class TelemetryWorker : BackgroundService var extra = utilities.RunAll(token); if (extra.Count > 0) logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count); + + // NARROW ON PURPOSE. The runner executes every .ps1 in the utilities + // directory, but only this one known key is attached to the report. + // Forwarding the whole dictionary would mean any script dropped into + // that directory starts uploading its output to the server on every + // cadence - see the signing caveat in PowerShellUtilityRunner's + // header. Widening this to the full map is the intended end state of + // the IAgentUtilityRunner seam, but it needs script signing or an + // allowlist first. Until then, add a key here per utility. + if (extra.TryGetValue(DellUpdatesUtilityName, out var dellUpdates)) + report.DellUpdates = dellUpdates; } logger.LogInformation( diff --git a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs new file mode 100644 index 00000000..04a42006 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs @@ -0,0 +1,362 @@ +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Collects the session-scoped data that does not exist in the agent's own +// context, by running a short PowerShell probe IN THE CALLING PROCESS'S +// SESSION and writing the result to the user's spool file. +// +// Collects only data that genuinely does not exist in the agent's own context: +// netuse - mapped drives are per logon session; "net use" from +// LocalSystem returns SYSTEM's (empty) mapping table, never the +// operator's Z: to the file server. +// userlisteners- TCP listeners owned by the signed-in user's processes, which +// is what identifies an operator-launched DNC or CMM listener. +// idleseconds - GetLastInputInfo is per session, so session 0 cannot answer +// it. Sampled at probe time only, so treat it as a coarse +// "was anyone at the machine" signal, not live presence. +// +// This runs from `shopdbagent.exe --collect-usercontext`, which the tray - and +// only the tray - invokes, because the tray is already running as the signed-in +// operator. The service does NOT call this: it previously tried to descend into +// the session with CreateProcessAsUser, which is the direction that has to +// duplicate a token, is what endpoint protection watches for, and produced +// nothing on a live endpoint. Nothing here crosses a privilege boundary now; +// the probe simply runs as whoever the tray is. +// +// The script is embedded rather than shipped as a .ps1 so the probe cannot drift +// from the agent binary or be tampered with on disk between cycles. +public sealed class UserContextProbe +{ + // The spool directory is injected by BuildProbeScript rather than derived in + // the script, so the writer cannot disagree with the reader. See + // UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong. + private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__"; + + private const string ProbeScriptTemplate = """ + $ErrorActionPreference = 'Stop' + $result = [ordered]@{} + # Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the + # writer and the reader resolve the same directory. Deliberately NOT + # $env:USERPROFILE: the tray can inherit another account's environment on a + # locked-down image, which silently wrote this file into the wrong profile. + $spool = '__SHOPDB_SPOOL_DIR__' + $target = Join-Path $spool 'usercontext.json' + $temporary = "$target.tmp" + + try { + New-Item -ItemType Directory -Path $spool -Force | Out-Null + + $netuse = @() + try { + Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction Stop | + ForEach-Object { + $netuse += [ordered]@{ + localname = $_.LocalName + remotename = $_.RemoteName + status = $_.Status + username = $_.UserName + persistent = [bool]$_.Persistent + } + } + } catch { + $result['netuseerror'] = $_.Exception.Message + } + $result['netuse'] = $netuse + + # Second, independent view of the same drives. Win32_NetworkConnection + # is the "net use" table and Get-SmbMapping is the SMB client's own, + # and they disagree: a share can be Unavailable in one and absent or + # OK in the other. Neither is authoritative, so both are reported as + # collected and the server decides what to show. Normalising them + # here would discard exactly the disagreement that is diagnostic. + $smbmapping = @() + try { + Get-SmbMapping -ErrorAction Stop | + ForEach-Object { + $smbmapping += [ordered]@{ + localpath = $_.LocalPath + remotepath = $_.RemotePath + status = [string]$_.Status + } + } + } catch { + # Absent on older builds and in some session types. A missing + # source is not a drive fault, so it is recorded and left out + # rather than reported as a failed mapping. + $result['smbmappingerror'] = $_.Exception.Message + } + $result['smbmapping'] = $smbmapping + + $listeners = @() + try { + $ownprocesses = @{} + Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + $ownprocesses[$_.Id] = $_.ProcessName + } + Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { $ownprocesses.ContainsKey($_.OwningProcess) } | + ForEach-Object { + $listeners += [ordered]@{ + localaddress = $_.LocalAddress + localport = $_.LocalPort + processid = $_.OwningProcess + processname = $ownprocesses[$_.OwningProcess] + } + } + } catch { + $result['userlistenerserror'] = $_.Exception.Message + } + $result['userlisteners'] = $listeners + + # Idle time must be measured inside the user's session: + # GetLastInputInfo is per session, so asking from the agent's + # session 0 would report the service's own idle time, which is + # meaningless. This is what distinguishes "powered on" from + # "someone is actually working at this machine". + try { + # Built as a single-quoted string rather than a here-string: a + # here-string terminator must sit at column 0, which cannot be + # expressed inside an indented C# raw string literal. + $signature = 'using System; using System.Runtime.InteropServices; ' + + 'public class ShopDbIdle { ' + + '[StructLayout(LayoutKind.Sequential)] ' + + 'struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } ' + + '[DllImport("user32.dll")] ' + + 'static extern bool GetLastInputInfo(ref LASTINPUTINFO info); ' + + 'public static long IdleSeconds() { ' + + 'LASTINPUTINFO info = new LASTINPUTINFO(); ' + + 'info.cbSize = (uint)Marshal.SizeOf(info); ' + + 'if (!GetLastInputInfo(ref info)) return -1; ' + + 'return (long)((uint)Environment.TickCount - info.dwTime) / 1000; } }' + Add-Type -TypeDefinition $signature -ErrorAction Stop + $idle = [ShopDbIdle]::IdleSeconds() + if ($idle -ge 0) { $result['idleseconds'] = $idle } + } catch { + $result['idlesecondserror'] = $_.Exception.Message + } + + $result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o') + $result['username'] = $env:USERNAME + + # Stamp the token's elevation state. The username alone does not + # settle it: the SAME account elevated and unelevated gets two + # different logon tokens and therefore two different drive tables, + # so a spool from an elevated ShopFloor session passes the account + # check while still reporting every drive as broken. + try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + $result['elevated'] = [bool] $principal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } catch { + $result['elevatederror'] = $_.Exception.Message + } + + # Write to a temporary name and rename into place, so the reader can + # never observe a half-written file and discard a good result. + $result | ConvertTo-Json -Depth 4 -Compress | + Set-Content -Path $temporary -Encoding UTF8 -NoNewline + Move-Item -Path $temporary -Destination $target -Force + } catch { + exit 1 + } + exit 0 + """; + + // Substitute the resolved spool directory into the template. The path goes in + // as a PowerShell single-quoted literal, so an embedded quote is doubled and + // nothing else in the path is interpreted - backslashes and $ are literal + // inside single quotes, which is exactly what a Windows path needs. + private static string BuildProbeScript(string spoolDirectory) + => ProbeScriptTemplate.Replace( + SpoolDirectoryToken, spoolDirectory.Replace("'", "''")); + + // How long the probe is allowed to take. Generous: this no longer runs on + // the telemetry path, so an unresponsive WMI call delays nothing but itself. + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60); + + // powershell.exe's exit code when it rejects its command line and prints + // usage instead of running anything. Named so the failure is recognisable. + private const int PowerShellStartupFailure = -196608; + + private readonly ILogger logger; + + public UserContextProbe(ILogger logger) + { + this.logger = logger; + } + + // Run the probe in THIS process's session and write the spool file. + // Returns true only when the spool file is present on disk afterwards. + // + // Called from the --collect-usercontext entry point, which the tray runs as + // the signed-in operator. Never throws: this is background collection and a + // failure must not take the tray or the agent process down with it. + public bool Collect() + { + var spoolDirectory = UserContextSpool.PinnedSpoolDirectory(); + var resultPath = UserContextSpool.PinnedResultPath(); + + try + { + // Logged before the launch, not after. When this goes wrong the + // interesting question is always "which directory did it target", + // and a failure later in this method must not take the answer with + // it. + logger.LogInformation( + "Running the user-context probe as {User}; spool target {Path}", + Environment.UserName, resultPath); + + // The probe writes a .tmp beside the target and moves it into place, + // so the directory has to exist before powershell.exe starts. The + // script creates it too, but doing it here means a permission problem + // is reported by the agent with a real exception rather than showing + // up as an opaque non-zero exit code. + try + { + Directory.CreateDirectory(spoolDirectory); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + logger.LogWarning(error, + "Could not create the user-context spool directory at {Directory}", + spoolDirectory); + return false; + } + // Passed as -EncodedCommand rather than staged as a .ps1 and run with + // -File. The script used to be written into %TEMP% and executed from + // there, which put a dependency on that file still existing, and still + // being readable, at the moment powershell.exe started. On a hardened + // endpoint that window is not safe: endpoint protection quarantines a + // freshly written script in the temp directory between the write and + // the launch, and roaming/redirected profiles can make the path + // unusable outright. + // + // When powershell.exe cannot use its -File argument it prints its usage + // banner and exits -196608 (0xFFFD0000) WITHOUT running a single + // statement. That is a startup failure, not a script failure, so the + // script's own try/catch never runs, nothing is logged about drives, + // and the operator sees a silent "no mapped drives reported" while the + // log only shows the bare exit code. -EncodedCommand carries the script + // in the command line itself, so there is no file to lose and no + // execution-policy or quarantine step between us and the probe. + var encoded = Convert.ToBase64String( + System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory))); + + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = + "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand " + + encoded, + WorkingDirectory = Path.GetTempPath(), + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = System.Diagnostics.Process.Start(startInfo); + if (process is null) + { + logger.LogWarning("The user-context probe could not be started."); + return false; + } + + if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds)) + { + logger.LogWarning( + "The user-context probe did not finish within {Seconds} seconds.", + ProbeTimeout.TotalSeconds); + TryKill(process); + return false; + } + + if (process.ExitCode != 0) + { + // 0xFFFD0000 is powershell.exe refusing its own command line and + // printing usage: the probe never ran. Called out by name because + // as a bare number it reads like a script error and sends the + // reader looking for a WMI or drive fault that is not there. + var detail = process.ExitCode == PowerShellStartupFailure + ? " (powershell.exe rejected its command line; the probe did not run)" + : string.Empty; + logger.LogWarning( + "The user-context probe exited with code {ExitCode}.{Detail}", + process.ExitCode, detail); + return false; + } + + // Exit code 0 is necessary but NOT sufficient. The probe used to be + // trusted on its exit code alone, which meant a run that wrote its + // file into a different profile entirely still reported success - the + // log claimed a spool had been written for weeks while nothing was + // ever there. Confirm the file exists where the reader will look. + if (!File.Exists(resultPath)) + { + logger.LogWarning( + "The user-context probe reported success but no spool file is " + + "present at {Path}.", resultPath); + return false; + } + + // Byte count alone does not distinguish a healthy spool from a + // well-formed empty one, and "0 drives" is the exact symptom this + // whole path exists to diagnose. Summarising the contents means a + // single log line from any given task run answers whether the probe + // saw the operator's drives, without needing the spool itself. + var summary = SummariseSpool(resultPath); + logger.LogInformation( + "The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}", + new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary); + return true; + } + catch (Exception exception) + { + logger.LogWarning(exception, "The user-context probe failed."); + return false; + } + } + + // One-line description of what the probe actually captured, for the run log. + // Best-effort by design: this is diagnostic colour on an already-successful + // run, so a parse problem here must never turn that run into a failure. + private static string SummariseSpool(string resultPath) + { + try + { + using var document = System.Text.Json.JsonDocument.Parse( + File.ReadAllText(resultPath)); + var root = document.RootElement; + + static int CountOf(System.Text.Json.JsonElement root, string name) => + root.TryGetProperty(name, out var value) + && value.ValueKind == System.Text.Json.JsonValueKind.Array + ? value.GetArrayLength() + : 0; + + var user = root.TryGetProperty("username", out var stamped) + ? stamped.GetString() + : "(unstamped)"; + + return $"user={user} netuse={CountOf(root, "netuse")} " + + $"smbmapping={CountOf(root, "smbmapping")} " + + $"listeners={CountOf(root, "userlisteners")}"; + } + catch (Exception) + { + return "(could not summarise)"; + } + } + + private void TryKill(System.Diagnostics.Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception exception) + { + logger.LogDebug(exception, "Could not stop the timed-out user-context probe."); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs b/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs new file mode 100644 index 00000000..af07004d --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs @@ -0,0 +1,179 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; + +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// The service-to-tray request channel for an on-demand user-context refresh. +// +// WHY THIS EXISTS AT ALL. Mapped drives are a property of the operator's logon +// token, not of the machine. The agent is a session-0 LocalSystem service, so it +// CANNOT enumerate them - not by impersonation, not by CreateProcessAsUser, not +// by starting the probe task itself. That approach was built once and reverted; +// see the tombstone in Program.cs where UserContextProbe is deliberately kept +// out of the service's DI container. The only process on the box that can see +// the right drive table is one already running inside the operator's session: +// the tray. +// +// So the service does not probe. It ASKS. This channel is the ask, and the +// spool file remains the answer. +// +// WHY A NAMED EVENT rather than a pipe or a socket. The request carries no data +// - it is a single bit, "please re-probe now" - and the reply already has a +// transport (the spool file, whose collectedat stamp the service watches +// advance). A kernel event is the smallest thing that crosses the session-0 +// boundary, needs no serialization, no port, no firewall rule, and no cleanup if +// either side dies. A file-watcher trigger would also work but leaves litter on +// disk when the service is killed mid-request. +// +// THE Global\ PREFIX IS LOAD-BEARING. Without it the name is scoped to the +// caller's session, so the service (session 0) and the tray (session 1+) would +// create two unrelated events with the same name and never see each other. That +// failure is silent: both sides "work", nothing is ever refreshed. +// +// THE DACL IS ALSO LOAD-BEARING. The service creates the event as LocalSystem, +// which by default produces an object a non-elevated user cannot even open. The +// tray runs unelevated as ShopFloor by design (an ELEVATED tray is refused by +// the probe, because it would read the wrong drive table). So the creator grants +// Users explicit Synchronize + Modify rights, or the tray gets access-denied on +// open and the refresh silently never happens. +public sealed class UserContextRefreshChannel +{ + // Session-global so session 0 and the interactive session share one object. + public const string EventName = @"Global\ShopDbEndpointAgent.UserContextRefresh"; + + private readonly ILogger logger; + + // Takes the base ILogger rather than ILogger: both the service (generic + // host) and the tray (its own file-backed logger named "Tray") construct + // this, and the tray has no DI container to resolve a typed logger from. + public UserContextRefreshChannel(ILogger logger) + { + this.logger = logger; + } + + // Signal the tray that a fresh probe is wanted. + // + // Returns false when the event could not be opened or created, which is the + // normal state on a machine with nobody signed in and no tray running. The + // caller treats that as "no refresh available" and reports what it already + // has - a stale mapped-drive list is worth strictly more than a missed + // heartbeat, because telemetry IS the liveness signal. + // + // AutoReset, so exactly one waiting tray is released per request and the + // event does not latch on for every subsequent listener iteration. + [SupportedOSPlatform("windows")] + public bool RequestRefresh() + { + try + { + using var handle = OpenOrCreate(out var created); + if (handle is null) + return false; + + handle.Set(); + logger.LogDebug( + "Requested a user-context refresh on {Event} (created={Created})", + EventName, created); + return true; + } + catch (Exception error) + { + // Never throws to the caller. This sits directly on the telemetry + // path, and a refresh is an enhancement to the report, not a + // precondition for sending it. + logger.LogDebug(error, + "Could not request a user-context refresh on {Event}", EventName); + return false; + } + } + + // Tray side: block until the service asks for a refresh, the timeout expires, + // or the token is cancelled. Returns true only for an actual request. + [SupportedOSPlatform("windows")] + public bool WaitForRequest(TimeSpan timeout, CancellationToken token) + { + try + { + using var handle = OpenOrCreate(out _); + if (handle is null) + return false; + + var index = WaitHandle.WaitAny( + new[] { handle, token.WaitHandle }, timeout); + + // Index 1 is cancellation, WaitTimeout is the idle path; only index 0 + // is a genuine request. + return index == 0; + } + catch (Exception error) + { + logger.LogDebug(error, + "Could not wait on the user-context refresh channel {Event}", EventName); + return false; + } + } + + // Open the shared event, creating it if this process is first. + // + // Either side may start first - the service can boot before anyone signs in, + // and the tray can start before the service finishes initializing - so both + // sides use the same create-or-open call rather than designating an owner. + // Whoever wins applies the DACL; the loser opens the existing object. + [SupportedOSPlatform("windows")] + private EventWaitHandle? OpenOrCreate(out bool created) + { + created = false; + try + { + var security = new EventWaitHandleSecurity(); + + // BUILTIN\Users by SID, not by name: on a non-English Windows the + // group is not called "Users", and this agent ships to whatever + // locale the site runs. Same reasoning as the task registration. + var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + users, + EventWaitHandleRights.Synchronize | EventWaitHandleRights.Modify, + AccessControlType.Allow)); + + // FullControl for SYSTEM and Administrators so the service retains + // ownership semantics regardless of which side created the object. + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + system, EventWaitHandleRights.FullControl, AccessControlType.Allow)); + + var admins = new SecurityIdentifier( + WellKnownSidType.BuiltinAdministratorsSid, null); + security.AddAccessRule(new EventWaitHandleAccessRule( + admins, EventWaitHandleRights.FullControl, AccessControlType.Allow)); + + return EventWaitHandleAcl.Create( + initialState: false, + mode: EventResetMode.AutoReset, + name: EventName, + createdNew: out created, + eventSecurity: security); + } + catch (UnauthorizedAccessException) + { + // The object exists with a DACL that does not admit this caller. + // Retry read-side-only rights: the tray only ever needs to wait, and + // an event created by an older agent build may not grant Modify. + try + { + return EventWaitHandle.OpenExisting(EventName); + } + catch (Exception error) + { + logger.LogDebug(error, + "The user-context refresh event {Event} exists but is not accessible", + EventName); + return null; + } + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs new file mode 100644 index 00000000..8bb5e8a3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs @@ -0,0 +1,389 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Reads the result file that the tray-run user-context probe writes into the +// signed-in user's ShopDB spool folder. +// +// Why a file and not a return value: the agent is a session-0 LocalSystem +// service, so anything session-scoped (mapped drives from "net use", per-user +// network state) simply does not exist in its own context. The probe runs as the +// operator under the tray, in a completely separate process with no pipe back to +// the service. A file in the user's own profile is the handoff, and the user can +// always write there without elevation. +// +// Freshness is judged from the 'collectedat' stamp inside the file, NOT by +// deleting the file around each read. Writer and reader are now independent +// processes on unrelated schedules, so a delete-then-read would race the tray +// and usually destroy a perfectly good result before it was ever reported. The +// file is therefore left alone and simply ignored once it ages out, which also +// means a logged-off machine stops reporting session data instead of pinning the +// last operator's mapped drives forever. +public sealed class UserContextSpool +{ + public const string SpoolFolderName = "ShopDB"; + public const string ResultFileName = "usercontext.json"; + + // The one definition of where the spool lives. Both the probe (writer) and + // this class (reader) call it, so the two can no longer disagree. + // + // The writer used to build its own path from $env:USERPROFILE while the + // reader used the pinned ShopFloor profile. On a locked-down image those are + // not the same directory - the tray is launched through explorer.exe by an + // elevated installer and can inherit another account's environment block, as + // the tray already documents for %TEMP%. The probe then wrote a perfectly + // good file into the wrong profile, exited 0, and the service found nothing: + // no spool ever appeared and no mapped drive was ever reported. + public static string PinnedSpoolDirectory() + => Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName); + + public static string PinnedResultPath() + => Path.Combine(PinnedSpoolDirectory(), ResultFileName); + + // A probe result is small - a handful of mapped drives and listeners. A file + // larger than this is either corrupt or hostile, and we will not parse it. + private const long MaxResultBytes = 512 * 1024; + + // A result older than this is treated as absent. Sized at roughly three tray + // probe intervals (30 min each), so a single missed run never blanks the card + // but genuinely stale session data retires quickly. + // + // This was 24h when the reader deleted the file after each read; the delete + // was doing the real retiring and the age check was only a backstop. Now that + // the spool persists, this value IS the staleness bound, so it is sized + // against the writer's cadence rather than the server's quiet-report window. + // + // Not read from the server: this is an offline-safe local bound, and the + // agent must retire stale session data even when it cannot reach ShopDB. + private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90); + + private readonly ILogger logger; + + public UserContextSpool(ILogger logger) + { + this.logger = logger; + } + + // Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because + // every shop-floor endpoint signs in as the same local ShopFloor account. + // + // This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory, + // and that lookup is precisely what fails on a hardened endpoint: querying + // another session's token is a privileged operation that endpoint protection + // blocks. When it failed the service simply reported "no session data" while + // the tray was writing a perfectly good file, so writer and reader silently + // disagreed about the location and the operator saw an empty card. + // + // The token lookup is kept ONLY as a fallback for a machine whose operator + // account is named something else. If both are unavailable there is no + // session data, which costs mapped drives and idle time but never affects + // telemetry, commands or inventory. + public string? TryGetResultPath(out string? reason) + { + var pinned = FileLoggerProvider.ShopFloorProfileDirectory(); + if (Directory.Exists(pinned)) + { + reason = null; + return PinnedResultPath(); + } + + var profileDirectory = + InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error); + if (string.IsNullOrWhiteSpace(profileDirectory)) + { + reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, " + + "and the interactive profile could not be resolved either: " + + (error ?? "unknown error"); + return null; + } + + reason = null; + return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName); + } + + // Remove a result file. Only used to discard one that is corrupt or has aged + // out; a valid result is left in place for the next read. + public void ClearStaleResult(string resultPath) + { + try + { + if (File.Exists(resultPath)) + { + File.Delete(resultPath); + logger.LogDebug( + "Removed a stale user-context spool file at {Path}", resultPath); + } + } + catch (Exception exception) + { + // Not fatal on its own, but it does mean we cannot trust whatever we + // read next, so the caller must know. + logger.LogWarning(exception, + "Could not remove the stale user-context spool file at {Path}", resultPath); + } + } + + public void EnsureSpoolDirectory(string resultPath) + { + var directory = Path.GetDirectoryName(resultPath); + if (string.IsNullOrWhiteSpace(directory)) + return; + + try + { + Directory.CreateDirectory(directory); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not create the user-context spool directory at {Directory}", directory); + } + } + + // Read the probe result without consuming it. Returns null when there is no + // usable, sufficiently recent result, which the caller reports as "no session + // data this cycle" rather than as empty data - absent and empty mean + // different things here. + public Dictionary? TryReadResult(string resultPath) + { + try + { + if (!File.Exists(resultPath)) + return null; + + var info = new FileInfo(resultPath); + if (info.Length == 0) + { + logger.LogDebug("The user-context spool file was empty."); + return null; + } + + if (info.Length > MaxResultBytes) + { + logger.LogWarning( + "The user-context spool file was {Bytes} bytes, above the {Limit} byte " + + "limit; discarding it.", + info.Length, MaxResultBytes); + return null; + } + + var json = File.ReadAllText(resultPath); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var parsed = JsonSerializer.Deserialize>(json); + if (parsed is null || parsed.Count == 0) + return null; + + if (IsExpired(parsed, resultPath)) + return null; + + if (IsWrongAccount(parsed)) + return null; + + if (IsElevatedCollection(parsed)) + return null; + + return parsed; + } + catch (JsonException exception) + { + // A truncated file is the expected shape of this failure: the probe + // was still writing when we looked. Next cycle gets a clean one. + logger.LogWarning(exception, + "The user-context spool file was not valid JSON; discarding it."); + return null; + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not read the user-context spool file at {Path}", resultPath); + return null; + } + } + + // Read the spool WITHOUT removing it. The probe overwrites the file on every + // run (write .tmp, move into place), so the newest result always replaces the + // previous one and there is nothing for the reader to clean up. + // + // This used to delete the file after a successful read, to stop a dead tray + // re-reporting the same drives forever. The collectedat age check already + // does that job, and deleting created a worse problem: lastUserContext in the + // collector is in-memory only, so a service restart wiped the cache while the + // spool had already been consumed. The agent then reported NO mapped drives + // until the tray's next probe - and the installer restarts the service and + // the tray on every install, so a fresh install was guaranteed to show an + // empty card at exactly the moment someone looked at it. + // + // The contents are the operator's own session data, visible to them in their + // own context, so leaving the file in place costs nothing. + public Dictionary? ConsumeResult(string resultPath) + => TryReadResult(resultPath); + + // The collectedat stamp on disk, or null when there is no readable stamp. + // + // This is how the service PROVES a refresh actually happened rather than + // assuming it. Signalling the tray is asynchronous and the probe takes a + // second or two, so a caller that simply slept would either send the old + // spool anyway or pad the delay and hope. Watching this value advance past + // the pre-request stamp is the only honest way to know the file in front of + // us is the answer to OUR request and not the previous one. + // + // Deliberately NOT routed through TryReadResult: this must report the stamp + // of whatever is on disk, including a spool that is expired, elevated, or + // from the wrong account. Those are all rejected later for reporting, but + // for ordering we only care whether the file was rewritten - conflating "not + // usable" with "not fresh" would make the service wait out the full timeout + // on every cycle for a machine whose spool is being rejected for some other + // reason. + // + // Also deliberately NOT the file's LastWriteTime, for the reason IsExpired + // gives: a spool can be copied or restored with a roaming profile and carry + // a misleading filesystem timestamp. The stamp inside the file is written by + // the probe itself. + public DateTimeOffset? TryReadCollectedAt(string resultPath) + { + try + { + if (!File.Exists(resultPath)) + return null; + + var info = new FileInfo(resultPath); + if (info.Length == 0 || info.Length > MaxResultBytes) + return null; + + var json = File.ReadAllText(resultPath); + if (string.IsNullOrWhiteSpace(json)) + return null; + + var parsed = JsonSerializer.Deserialize>(json); + if (parsed is null + || !parsed.TryGetValue("collectedat", out var raw) + || raw is null) + { + return null; + } + + if (DateTimeOffset.TryParse( + raw.ToString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var collectedat)) + { + return collectedat; + } + + return null; + } + catch (Exception exception) + { + // A torn read while the probe is mid-write is the expected failure + // here and is not worth a warning: the caller polls, so the next + // attempt a moment later sees the completed file. + logger.LogDebug(exception, + "Could not read the user-context collectedat stamp at {Path}", resultPath); + return null; + } + } + + // Reject a spool that some other account produced. + // + // Pinning the path guarantees the file lands where the reader looks, but it + // cannot guarantee WHO ran the probe. Mapped drives and idle time are per + // logon session, so a probe that ran as an administrator (for example a tray + // that inherited an elevated installer's environment) would enumerate that + // account's drive table and write it into the operator's spool. That is worse + // than reporting nothing, because the card would look populated and correct + // while showing drives the operator does not have. + // + // The probe stamps $env:USERNAME into the file, so the mismatch is checkable. + // Logged at warning, not debug: this is a misconfigured endpoint, not a quiet + // absence of data. + private bool IsWrongAccount(Dictionary parsed) + { + if (!parsed.TryGetValue("username", out var raw) || raw is null) + { + logger.LogWarning( + "The user-context spool has no username stamp; ignoring it because " + + "the session it describes cannot be confirmed."); + return true; + } + + var username = raw.ToString(); + if (string.Equals(username, FileLoggerProvider.ShopFloorUserName, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + logger.LogWarning( + "The user-context spool was written by {Actual}, not {Expected}; " + + "ignoring it. Session data is per logon session, so this would " + + "report the wrong account's mapped drives.", + username, FileLoggerProvider.ShopFloorUserName); + return true; + } + + // An elevated probe runs under a different logon token to the operator's + // interactive one and enumerates a different, usually empty, drive table. + // The agent refuses to collect while elevated, so a spool stamped elevated + // should not exist; this rejects any that predates that guard or arrives + // from a hand-run --collect-usercontext in an admin shell. + // + // Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here + // rather than rejected. An unstamped spool is simply one written by an older + // agent, and treating that as elevated would blank out session data across a + // fleet mid-upgrade. An unstamped spool has already had to pass the username + // check to reach this point. + private bool IsElevatedCollection(Dictionary parsed) + { + if (!parsed.TryGetValue("elevated", out var raw) || raw is null) + return false; + + var elevated = raw is JsonElement element + ? element.ValueKind == JsonValueKind.True + : string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase); + + if (!elevated) + return false; + + logger.LogWarning( + "The user-context spool was collected by an ELEVATED process; ignoring " + + "it. Mapped drives are per logon token, so an elevated probe reports " + + "the wrong drive table even under the right account. Collection should " + + "come from the 'ShopDB user-context probe' scheduled task, which runs " + + "at RunLevel Limited."); + return true; + } + + // Age is taken from the stamp the probe wrote, not from the file's last + // write time, because the file can be copied or restored with a roaming + // profile and carry a misleading timestamp. A result with no readable stamp + // is treated as expired: we cannot prove it is current, so we will not claim + // it is. + private bool IsExpired(Dictionary parsed, string resultPath) + { + if (!parsed.TryGetValue("collectedat", out var raw) || raw is null + || !DateTimeOffset.TryParse( + raw.ToString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, + out var collectedat)) + { + logger.LogDebug( + "The user-context spool file has no usable collectedat stamp; ignoring it."); + return true; + } + + var age = DateTimeOffset.UtcNow - collectedat; + if (age <= MaxResultAge) + return false; + + logger.LogDebug( + "The user-context spool file is {Hours} hours old, past the {Limit} hour limit.", + Math.Round(age.TotalHours, 1), MaxResultAge.TotalHours); + ClearStaleResult(resultPath); + return true; + } +} diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs index bee73158..ddaaee67 100644 --- a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs +++ b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs @@ -92,6 +92,57 @@ else var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe"); Check("8.3 short name resolving into the approved root", true, shortRoot, policy); +// --- ScheduledTaskPolicy ---------------------------------------------------- +// Pure string logic, unlike LaunchPathPolicy: a task path names an entry in the +// Task Scheduler namespace, not a file, so there is no disk state to plant. The +// cases that matter are the ones where a candidate LOOKS like an approved entry +// as a string but is not the same task. + +void CheckTask(string label, bool expectAccept, string candidate, ScheduledTaskPolicy policy) +{ + var ok = policy.TryValidate(candidate, out var full, out var reason); + var pass = ok == expectAccept; + if (!pass) failures++; + var verdict = pass ? "PASS" : "FAIL"; + var got = ok ? $"accepted -> {full}" : $"rejected ({reason})"; + Console.WriteLine($"{verdict} {label}"); + Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}"); +} + +Console.WriteLine(); +Console.WriteLine("--- ScheduledTaskPolicy ---"); + +var taskPolicy = new ScheduledTaskPolicy(new[] { @"\ReportIP", @"\SFLD\" }); + +CheckTask("exact approved task", true, @"\ReportIP", taskPolicy); +// Task Scheduler is case-insensitive, so the policy must be too, or the same +// task typed differently would be refused. +CheckTask("approved task, different case", true, @"\reportip", taskPolicy); +// The operator should not have to know the rooted form. +CheckTask("approved task without leading separator", true, "ReportIP", taskPolicy); +CheckTask("approved task with forward slashes", true, "/ReportIP", taskPolicy); +// The prefix-suffix confusion this policy exists to prevent: "\ReportIP" must +// NOT authorize "\ReportIPEvil". +CheckTask("sibling task sharing an approved prefix", false, @"\ReportIPEvil", taskPolicy); +CheckTask("unapproved task", false, @"\Evil", taskPolicy); +CheckTask("empty task path", false, "", taskPolicy); +// Folder entries authorize everything beneath them, but not a sibling folder +// whose name merely starts the same way. +CheckTask("task inside an approved folder", true, @"\SFLD\ApplyDSCConfig", taskPolicy); +CheckTask("folder itself", true, @"\SFLD", taskPolicy); +CheckTask("sibling folder sharing the prefix", false, @"\SFLDEvil\Thing", taskPolicy); +// Traversal would let an approved prefix string-match something else entirely. +CheckTask("traversal out of an approved folder", false, @"\SFLD\..\Evil", taskPolicy); +// Defense in depth: the invocation uses ArgumentList, but a task path with +// these characters is not one we should be starting. +CheckTask("argument-injection characters", false, "\\ReportIP\" & calc", taskPolicy); +CheckTask("control character", false, "\\Report\u0000IP", taskPolicy); +// An empty override must fall back to the shipped defaults, never to "allow +// everything". +var defaultPolicy = new ScheduledTaskPolicy(Array.Empty()); +CheckTask("shipped default is honored when override is empty", true, @"\ReportIP", defaultPolicy); +CheckTask("empty override does not mean allow-all", false, @"\Evil", defaultPolicy); + Console.WriteLine(); Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET"); return failures == 0 ? 0 : 1; diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj index 9112673f..1736af61 100644 --- a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj +++ b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj @@ -16,5 +16,9 @@ + + diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 322eb69c..90a0532c 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -8,11 +8,70 @@ true ShopDbEndpointAgent.Tray shopdb-endpoint-tray + - 1.0.0 + + + + + 1.4.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index f6b80d72..41039414 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Reflection; +using Microsoft.Extensions.Logging; + using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -25,8 +27,83 @@ internal sealed class TrayContext : ApplicationContext private readonly Icon brandIcon; private bool sending; + // Session-scoped collection. The service is LocalSystem and cannot see the + // operator's mapped drives, user-owned listeners, or input idle time, so the + // tray - which is already the operator - collects them and leaves the result + // in a spool file the service reads on its own cadence. + // + // Deliberately longer than the telemetry cadence: this data changes rarely + // (a mapped drive is mapped at logon and stays mapped), and each run spawns + // a short PowerShell process on a machine an operator is actively using. + private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(30); + + // Logon is the worst moment to add work: drive mappings and profile setup + // are still settling, and a probe fired now would capture an incomplete + // picture while competing with everything else starting up. + private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2); + + // ...but that reasoning only applies at logon. When the tray starts into a + // session that has been up for a while - which is what an installer restart + // or a manual relaunch does - nothing is settling and the full delay just + // extends the window where the server has no session data. The installer + // kills and restarts the tray on every install, so the 2-minute delay was + // being paid at exactly the moment someone was watching the card to see + // whether the install had worked. + private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15); + + // How long the session must have been up to count as established. Comfortably + // past the logon storm, well short of any realistic install-to-check gap. + private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5); + + private readonly System.Windows.Forms.Timer userContextTimer; + private bool userContextStarted; + private bool collectingUserContext; + + // Lets the service ask this tray to re-probe on demand, so a "Refresh + // Status" click in the WebUI produces genuinely current session data instead + // of whatever the last 30-minute tick happened to capture. The service + // cannot collect this itself at any price - mapped drives live on the + // operator's logon token - so the only way for an on-demand refresh to be + // accurate is for the request to end up here, in the operator's session. + private readonly UserContextRefreshChannel? refreshChannel; + private readonly CancellationTokenSource refreshCancellation = new(); + + // How long each wait blocks before looping. The wait returns false on + // timeout and the loop simply waits again, so this is not a deadline for + // anything - it just bounds how long the handle stays open, which lets a + // channel that was somehow broken get re-opened on the next pass instead of + // leaving the tray deaf until it restarts. + private static readonly TimeSpan RefreshWaitTimeout = TimeSpan.FromMinutes(5); + + // The UI thread's context, captured in the constructor. The listener runs on + // a background thread, but every probe attempt is marshalled back here so + // that collectingUserContext is only ever read or written from one thread. + // Without this the timer tick and a service request could both pass the + // guard and launch two probes at the same spool file. + private readonly SynchronizationContext? uiContext; + + // The tray logs into the operator's own profile + // (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log), NOT into the + // service's ProgramData directory. The operator cannot write there, and the + // file sink fails silently when it cannot create its directory, so tray-side + // failures previously left no trace anywhere. Keeping the two apart means a + // tray problem is diagnosable from a file the operator can actually reach. + private readonly FileLoggerProvider logProvider; + private readonly ILogger logger; + public TrayContext() { + logProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent"); + logger = logProvider.CreateLogger("Tray"); + + var startupDelay = ResolveStartupDelay(); + logger.LogInformation( + "Tray {Version} started; user-context probe in {Delay}, then every {Interval}.", + typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown", + startupDelay, UserContextInterval); + var menu = new ContextMenuStrip(); menu.Items.Add("Send Status now", null, OnSendStatus); menu.Items.Add("Report an issue...", null, OnReportIssue); @@ -45,6 +122,218 @@ internal sealed class TrayContext : ApplicationContext ContextMenuStrip = menu, }; trayIcon.DoubleClick += OnSendStatus; + + // A dead log sink cannot report itself through the log. Complaining via + // the logger would write the one message that explains the silence into + // the sink that is swallowing messages, so the balloon is the only + // channel that survives. Shown once at startup and never repeated: this + // is a "your diagnostics are blind" notice for whoever is standing at the + // machine, not a recurring nag about something the operator cannot fix. + if (logProvider.SinkProblem is not null) + { + trayIcon.ShowBalloonTip( + 10000, + "ShopDB Endpoint Agent", + "Tray logging is unavailable, so tray diagnostics will not be " + + $"recorded. {logProvider.SinkProblem}", + ToolTipIcon.Warning); + } + + userContextTimer = new System.Windows.Forms.Timer + { + Interval = (int)startupDelay.TotalMilliseconds, + }; + userContextTimer.Tick += OnUserContextTimer; + userContextTimer.Start(); + + uiContext = SynchronizationContext.Current; + + // Best-effort. A tray that cannot open the channel still probes on its + // timer, so the failure mode is "refresh is not immediate", not "session + // data stops working" - which is why this is caught and logged rather + // than allowed to take the tray down at startup. + try + { + refreshChannel = new UserContextRefreshChannel( + logProvider.CreateLogger(nameof(UserContextRefreshChannel))); + StartRefreshListener(); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not open the user-context refresh channel; on-demand " + + "refresh requests from the service will be ignored and session " + + "data will only update on the {Interval} timer.", + UserContextInterval); + } + } + + // Waits for service refresh requests for the life of the tray. + // + // A dedicated background thread rather than a Task: this blocks on a kernel + // wait handle essentially forever, which is exactly the workload that should + // not sit on a thread-pool thread. IsBackground keeps it from holding the + // process open at exit. + private void StartRefreshListener() + { + var thread = new Thread(() => + { + var token = refreshCancellation.Token; + while (!token.IsCancellationRequested) + { + try + { + if (!refreshChannel!.WaitForRequest(RefreshWaitTimeout, token)) + continue; + + logger.LogInformation( + "The service requested a user-context refresh."); + + // Marshalled, not awaited: the service is not waiting on a + // reply through this channel, it is watching the spool file's + // collectedat stamp. Our only job is to get the probe started + // on the UI thread and go back to waiting. + var post = uiContext; + if (post is not null) + post.Post(_ => _ = CollectUserContextAsync(), null); + else + _ = CollectUserContextAsync(); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) + { + // Never let a bad wait kill the listener - if this thread + // exits, on-demand refresh is silently dead for the rest of + // the session with no other symptom. + logger.LogWarning(exception, + "The user-context refresh listener failed; retrying."); + try + { + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + catch (Exception) + { + return; + } + } + } + }) + { + IsBackground = true, + Name = "ShopDB user-context refresh listener", + }; + + thread.Start(); + } + + // Fires once after the startup delay, then re-arms at the steady-state + // interval. Failures are swallowed on purpose: session data is a + // nice-to-have, and an operator must never see a dialog or a crashed tray + // because a probe could not read a drive mapping. + private async void OnUserContextTimer(object? sender, EventArgs e) + { + if (!userContextStarted) + { + userContextStarted = true; + userContextTimer.Interval = (int)UserContextInterval.TotalMilliseconds; + } + + await CollectUserContextAsync(); + } + + // Runs the probe once and returns when the spool has been rewritten. + // + // Shared by all three callers - the cadence timer, an on-demand service + // request, and "Send Status now" - so there is exactly one place that knows + // how to invoke the probe and how to interpret its exit codes. Returns true + // only when a spool file is actually on disk afterwards, which is what lets + // Send Status decide whether it is worth waiting on. + private async Task CollectUserContextAsync() + { + if (collectingUserContext) + { + // Another probe is already in flight against the same spool path. + // Its result is at most a second or two old and satisfies whatever + // this caller wanted, so coalescing is correct here, not a shortcut. + logger.LogDebug( + "A user-context probe is already running; skipping this request."); + return false; + } + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + // Previously a bare return. This is the single most likely reason for + // "no mapped drives" with nothing at all in any log, so it must say so. + logger.LogWarning( + "Skipping the user-context probe: {Agent} was not found beside the tray " + + "({Beside}) or at {Installed}.", + AgentExeName, AppContext.BaseDirectory, DefaultInstallDir); + return false; + } + + collectingUserContext = true; + try + { + logger.LogInformation("Running the user-context probe: {Agent}", agentExe); + var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext"); + + // Check the disk rather than announcing what the exit code implies. + // This line used to print a hardcoded path whenever the child exited + // 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for + // weeks while the probe was actually writing into a different profile + // and the service was finding nothing. + var resultPath = UserContextSpool.PinnedResultPath(); + if (exitCode == 0 && File.Exists(resultPath)) + { + logger.LogInformation( + "The user-context probe finished; spool present at {Spool}.", + resultPath); + return true; + } + else if (exitCode == 0) + { + logger.LogWarning( + "The user-context probe reported success but no spool file is " + + "present at {Spool}.", resultPath); + } + else if (exitCode == 2) + { + // The agent refused because it was elevated. Called out by name + // because as a bare exit code it reads like a probe fault and + // sends the reader looking for a drive or WMI problem that is + // not there - the fix is to start the tray unelevated, or let + // the scheduled task do the collecting. + logger.LogWarning( + "The user-context probe refused to run because this tray is " + + "ELEVATED. Mapped drives are per logon token, so an elevated " + + "probe would report the wrong drive table. Start the tray " + + "unelevated, or rely on the 'ShopDB user-context probe' " + + "scheduled task."); + } + else + { + logger.LogWarning( + "The user-context probe exited with code {ExitCode}; expected a " + + "spool at {Spool}.", exitCode, resultPath); + } + + return false; + } + catch (Exception exception) + { + // No longer silent. The agent logs its own detail, but if the process + // could not even be started the agent never ran and never logged. + logger.LogWarning(exception, "Could not run the user-context probe."); + return false; + } + finally + { + collectingUserContext = false; + } } private async void OnReportIssue(object? sender, EventArgs e) @@ -169,6 +458,18 @@ internal sealed class TrayContext : ApplicationContext try { + // Probe BEFORE reporting, and await it. The operator clicking this + // has just changed something - remapped a drive, signed in - and + // expects the result to show up. --once reads the spool file, so a + // probe started after it, or not awaited, would publish the previous + // probe's answer and the operator would click again and see the same + // stale card. + // + // Not fatal if it fails: a report with older session data still + // carries the heartbeat, inventory and events, which is most of what + // this button is for. + await CollectUserContextAsync(); + int exitCode = await RunAgentAsync(agentExe, "--once"); if (exitCode == 0) ShowBalloon("Send Status", "Status sent to ShopDB.", @@ -249,6 +550,35 @@ internal sealed class TrayContext : ApplicationContext startInfo.Environment.Remove(key); } + // Full delay at logon, short delay when the session is already established. + // + // Environment.TickCount64 is machine uptime, not session uptime, which is a + // deliberate simplification: the two differ only when someone signs out and + // back in without rebooting, and treating that as "established" is harmless - + // the profile is already built and the drives are already mapped in that + // case too. A failure to read it falls back to the conservative long delay. + private TimeSpan ResolveStartupDelay() + { + try + { + var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64); + if (uptime >= EstablishedSessionUptime) + { + logger.LogInformation( + "Session has been up {Uptime}; using the established-session probe delay.", + uptime); + return UserContextEstablishedDelay; + } + } + catch (Exception exception) + { + logger.LogDebug(exception, + "Could not read system uptime; using the logon probe delay."); + } + + return UserContextStartupDelay; + } + // Same directory first (the installer ships the tray next to the agent), // then the known Program Files install directory as a fallback. private static string? ResolveAgentExe() @@ -316,6 +646,15 @@ internal sealed class TrayContext : ApplicationContext { if (disposing) { + userContextTimer.Stop(); + userContextTimer.Dispose(); + + // Cancel and let the listener unwind on its own. The channel opens + // and closes its handle inside each wait rather than holding one, so + // there is nothing here to dispose. + refreshCancellation.Cancel(); + refreshCancellation.Dispose(); + trayIcon.Dispose(); brandIcon.Dispose(); } diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index b003d9f6..ffa770bd 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -22,15 +22,110 @@ using ShopDbEndpointAgent.Core; var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); +var collectUserContext = args.Any(a => + string.Equals(a, "--collect-usercontext", StringComparison.OrdinalIgnoreCase)); var reportPayloadPath = ArgValue(args, "--report"); var agentVersion = Assembly.GetExecutingAssembly() .GetName().Version?.ToString() ?? "0.0.0"; +// FIRST, ahead of everything else. This verb runs as the signed-in operator (the +// tray invokes it) and must not touch a single privileged resource on the way in. +// +// It used to sit below the bootstrap logger, the fault monitor, and the config +// load, which meant the operator-context process opened the SYSTEM-owned +// ProgramData log directory, attempted an EventLog source registration that only +// an administrator can perform, and read CollectorKey out of HKLM - all before it +// discovered it was only being asked to write a file into the user's own profile. +// On an EPM-managed host (CyberArk) those are exactly the calls a broker +// intercepts, and the registry read put the collector secret in a process that +// has no business holding it. +// +// It also has no config dependency by design: it only writes into the caller's +// own profile and never talks to the server, so it must still work on a machine +// whose CollectorKey has not been provisioned yet - otherwise session data would +// be missing on precisely the endpoints that are hardest to diagnose. +if (collectUserContext) +{ + // First statement in this branch. The scheduled task runs this exe directly + // in the operator's interactive session, and shopdbagent.exe is a console + // app, so without this a black window flashes up on every collection cycle. + // Done before any logging or probing so the window is gone immediately + // rather than lingering for the life of the probe. + ConsoleWindow.Hide(); + + // Logs to the OPERATOR's profile, not the service's ProgramData directory. + // A non-privileged user cannot write to the SYSTEM-owned log directory, and + // the file sink degrades to a no-op when it cannot create its directory, so + // pointing this at the service location meant every probe failure was + // discarded unread - which is exactly how a broken probe looked identical to + // a probe that was never invoked. + using var probeLogProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent"); + using var probeLoggerFactory = LoggerFactory.Create( + logging => logging.AddProvider(probeLogProvider)); + + var probeLogger = probeLoggerFactory.CreateLogger(); + ReportSinkProblem(probeLogProvider, "user-context probe"); + + // Refuse to collect from an elevated process. Drive letters are a property + // of the logon token, not the machine: an elevated process gets a different + // token to the operator's interactive one and enumerates a DIFFERENT, almost + // always empty, drive table. Measured on a live endpoint, the same two + // shares reported Unavailable from the elevated shell and OK from the + // operator's, in both Win32_NetworkConnection and Get-SmbMapping. + // + // Refusing here rather than filtering later is deliberate. An elevated probe + // does not fail - it succeeds and reports every mapped drive as broken, + // which is indistinguishable from a genuine outage at the far end. Better to + // write nothing and say why than to publish a confident wrong answer. + if (IsProcessElevated()) + { + probeLogger.LogError( + "Refusing to collect user context: this process is elevated. Mapped drives " + + "are per-logon-token, so an elevated probe reports the wrong drive table. " + + "Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " + + "--collect-usercontext from an admin shell.", + "ShopDB user-context probe"); + Console.Error.WriteLine( + "shopdbagent: refusing to collect user context from an elevated process; " + + "mapped drives would be wrong. Use the scheduled task."); + return 2; + } + + var probe = new UserContextProbe(probeLogger); + return probe.Collect() ? 0 : 1; +} + +// True when the current process holds the elevated (high-integrity) token. +static bool IsProcessElevated() +{ + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + } + catch + { + // Cannot tell. Treat as not elevated so an unreadable token does not + // suppress collection on a host where the probe would have worked; the + // spool's own account stamp is still checked on the way back in. + return false; + } +} + // Bootstrap logger first so config loading (including the registry read that // CyberArk/EPM may gate) is fully diagnosable before the host starts. using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory()); var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup"); +ReportSinkProblem(bootstrapFileLogger, "service"); var faultMonitor = new AgentFaultMonitor(bootstrapLogger); faultMonitor.Install(); @@ -191,10 +286,37 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config sp.GetRequiredService>())); } + // Persisted per-channel event watermark so each Windows event record is + // reported exactly once, instead of re-sending the newest window of every + // channel on every cadence tick. + services.AddSingleton(sp => new EventWatermarkStore( + sp.GetRequiredService>())); + + // Session-scoped collection. The agent is LocalSystem, so mapped drives and + // user-owned listeners are invisible to it. The tray, which already runs as + // the operator, writes the spool file; the service only reads it. + // + // UserContextProbe is deliberately NOT registered here. The service must + // never run the probe: descending into the session with CreateProcessAsUser + // is the reverted design, and leaving a resolvable dependency in the + // container is an invitation to reintroduce it. The probe is constructed + // only on the --collect-usercontext path, which the tray invokes. + services.AddSingleton(sp => new UserContextSpool( + sp.GetRequiredService>())); + + // The ask-the-tray side of the same split. Registering this is NOT a + // loophole around the paragraph above: the service still cannot collect + // session data, it can only request that the process which can does so, and + // then read the file that appears. The probe stays out of this container. + services.AddSingleton(sp => new UserContextRefreshChannel( + sp.GetRequiredService>())); + services.AddSingleton(sp => new EndpointCollector( sp.GetRequiredService>(), config, - sp.GetService())); + sp.GetService(), + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddSingleton(sp => new CollectorClient( config, sp.GetRequiredService>())); @@ -206,7 +328,9 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config agentVersion, sp.GetRequiredService>(), sp.GetService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); // Command channel: outbound long-poll for allowlisted WebUI commands, run // through the fail-closed executor. Hosted alongside telemetry but on its @@ -231,3 +355,22 @@ static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) logging.AddProvider(new FileLoggerProvider(logDirectory)); logging.SetMinimumLevel(LogLevel.Information); } + +// Say so, loudly, when a file sink could not open its directory. +// +// FileLoggerProvider never throws on construction and silently drops lines when +// its directory is unusable. That is the right behaviour - logging must not take +// the agent down - but it made two different failures indistinguishable: a probe +// that ran and failed, and a probe whose log had nowhere to go. The provider +// records the reason; this is the part that was missing, the caller actually +// asking for it. stderr is the only sink guaranteed to exist at this point, so +// that is where the complaint goes. +static void ReportSinkProblem(FileLoggerProvider provider, string which) +{ + if (provider.SinkProblem is null) + return; + + Console.Error.WriteLine( + $"shopdbagent: the {which} log sink is unusable and log lines will be " + + $"dropped - {provider.SinkProblem}"); +} diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 63e33106..ec456c3b 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,88 @@ enable ShopDbEndpointAgent shopdbagent - + + + + + - 1.6.3 + + 1.11.0 true win-x64 true diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 deleted file mode 100644 index 5a08d3db..00000000 --- a/agent/installer/install-shopdbagent.ps1 +++ /dev/null @@ -1,355 +0,0 @@ -# install-shopdbagent.ps1 -# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED -# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies -# the agent files to Program Files, runs a one-shot smoke test, then installs -# and starts the Windows service. -# -# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts -# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same -# values are also stamped into the installed appsettings.json as a fallback. -# -# The agent and tray are published as single-file self-contained executables, -# so copying the two .exe files plus appsettings.json is the complete payload. - -[CmdletBinding()] -param( - # ShopDB server root URL for this instance. REQUIRED - there is no default, - # because a default here means one site's hostname shipped to every site - # (ADR-015) and, worse, a working install pointed at somebody else's server. - # - # https is required. The collector key travels in the X-API-Key header and - # every telemetry payload follows it, so plain http puts both on the wire in - # clear. Pass -AllowInsecureHttp only for a local test instance. - [Parameter(Mandatory = $true)] - [string] $BaseUrl, - - # Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS. - # REQUIRED and never defaulted - a key in source is a key in git history, and - # this one is also the command channel's kill switch. - [Parameter(Mandatory = $true)] - [string] $CollectorKey, - - # Permit an http:// BaseUrl. For a local test instance only. - [switch] $AllowInsecureHttp, - - - [string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent", - [string] $ServiceName = "ShopDbEndpointAgent", - - # Interactive tray app that shows the system-tray icon and the on-demand - # "Send Status now" action. Separate process from the SYSTEM service (a - # service cannot draw a tray icon in a user session). Pass -NoTray to skip. - [string] $TrayExeName = "shopdb-endpoint-tray.exe", - [switch] $NoTray, - - # Add a Microsoft Defender path exclusion for the install directory. The - # agent and tray are unsigned single-file self-contained executables, which - # Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as - # a false positive and quarantine, killing the tray before its icon appears. - # Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions - # centrally or the binaries are code-signed). - [switch] $NoDefenderExclusion -) - -# Refuse cleartext unless it was asked for explicitly. The key is a bearer -# credential for the whole fleet's telemetry and command channel. -if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) { - throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance." -} - -$ErrorActionPreference = "Stop" -$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path - -function Assert-Admin { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - throw "This script must be run from an elevated (Run as administrator) PowerShell." - } -} - -Assert-Admin - -if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - throw "BaseUrl is not set. Pass -BaseUrl 'http://:' when running the script." -} - -# Write HKLM values through the explicit 64-bit registry view. If this script -# runs under 32-bit PowerShell (some management tooling launches x86), the -# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent -# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and -# see nothing, so config "goes missing" and the tray never autostarts. Pinning -# the view removes that dependency on the launching shell's bitness. -function Set-Hklm64Value { - param( - [Parameter(Mandatory)] [string] $SubKey, - [Parameter(Mandatory)] [string] $Name, - [Parameter(Mandatory)] [string] $Value - ) - $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey( - [Microsoft.Win32.RegistryHive]::LocalMachine, - [Microsoft.Win32.RegistryView]::Registry64) - try { - $key = $base.CreateSubKey($SubKey) - try { - $key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String) - } - finally { $key.Dispose() } - } - finally { $base.Dispose() } -} - -# Tear down any previously installed agent BEFORE touching files. The agent and -# tray are single-file self-contained exes; while the old service or tray is -# running it holds those .exe files open, so Copy-Item over them fails with a -# file-in-use error. Stopping the service, deleting it, and killing any lingering -# agent/tray processes up front makes the install a clean overwrite. Best-effort -# throughout: a missing service or process is the normal fresh-install case. -function Uninstall-PreviousAgent { - param( - [Parameter(Mandatory)] [string] $Service, - [Parameter(Mandatory)] [string] $TrayExe, - [Parameter(Mandatory)] [string] $TargetDir - ) - - $existing = Get-Service -Name $Service -ErrorAction SilentlyContinue - if ($existing) { - Write-Host " stopping and removing existing service $Service ..." - if ($existing.Status -ne "Stopped") { - try { Stop-Service -Name $Service -Force -ErrorAction Stop } - catch { Write-Warning " could not stop service: $($_.Exception.Message)" } - } - # sc.exe delete only marks the service for deletion; the SCM finishes the - # removal once every handle closes. Give it a moment so the subsequent - # sc.exe create does not hit a "marked for deletion" (1072) error. - sc.exe delete $Service | Out-Null - Start-Sleep -Seconds 2 - } - - # Kill any lingering worker or tray processes that would keep the exes - # locked. Match by the installed exe base names; the service host process is - # shopdbagent, the tray is the TrayExe base name. - $trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe) - foreach ($name in @("shopdbagent", $trayBase)) { - $procs = Get-Process -Name $name -ErrorAction SilentlyContinue - foreach ($p in $procs) { - Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..." - try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } - catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } - } - } - # Also kill anything still running out of the target install directory, - # regardless of exe name. A tray from an older build (different exe name) or - # one launched from a stale path would otherwise survive the name-based sweep - # above, keep its exe locked, and leave a duplicate icon once step 5 starts - # the fresh tray. Match on the executable path so we only touch our own dir. - $targetPrefix = $TargetDir.TrimEnd('\') + '\' - foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { - $path = $null - try { $path = $p.Path } catch { $path = $null } - if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..." - try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } - catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" } - } - } - # Brief settle so file handles are released before the copy overwrites them. - Start-Sleep -Milliseconds 500 -} - -Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan -Write-Host "BaseUrl : $BaseUrl" -Write-Host "InstallDir : $InstallDir" -Write-Host "ServiceName : $ServiceName" - -# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator -# typically extracts installer.zip into Downloads, so every file carries a -# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with -# "Windows protected your PC" the first time they run - including the [3/5] -# smoke test below, which launches shopdbagent.exe out of $sourceDir before the -# installed copy exists. Unblocking the source folder up front keeps the smoke -# test (and any manual run from the extract folder) clean; the installed copies -# get unblocked again in step 2a after they land in Program Files. Best-effort: -# a file with no MotW is a no-op. -Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..." -Get-ChildItem -Path $sourceDir -File | ForEach-Object { - try { Unblock-File -Path $_.FullName -ErrorAction Stop } - catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } -} - -# 0. Uninstall any previous agent first, so the file copy is a clean overwrite -# and does not fail on exes locked by a running service or tray. -Write-Host "`n[0/5] Removing any previously installed agent ..." -Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir - -# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value). -$regPath = "HKLM:\SOFTWARE\GE\ShopDB" -Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..." -Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl -Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey - -# 2. Copy agent files to the install directory. The agent and tray are -# single-file self-contained executables, so the .exe plus appsettings.json is -# the whole payload; there are no side-by-side runtime DLLs to copy. -Write-Host "[2/5] Copying agent files to $InstallDir ..." -New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -$agentSource = Join-Path $sourceDir "shopdbagent.exe" -if (-not (Test-Path $agentSource)) { - throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder." -} -Copy-Item -Path $agentSource -Destination $InstallDir -Force -$appsettings = Join-Path $sourceDir "appsettings.json" -$installedSettings = Join-Path $InstallDir "appsettings.json" -if (Test-Path $appsettings) { - Copy-Item -Path $appsettings -Destination $InstallDir -Force -} - -# Copy the interactive tray app next to the agent so its same-dir resolution -# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a -# service-only (headless) build. -$trayInstalled = $null -if (-not $NoTray) { - $traySource = Join-Path $sourceDir $TrayExeName - if (Test-Path $traySource) { - Copy-Item -Path $traySource -Destination $InstallDir -Force - $trayInstalled = Join-Path $InstallDir $TrayExeName - Write-Host " copied tray app $TrayExeName" - } - else { - Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)" - } -} - -# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM -# hive so the agent cannot READ the values the registry step just wrote. Stamp -# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads -# appsettings first, then lets the registry override it, so this is a safe -# no-op where the registry read works and the rescue where it does not. -if (Test-Path $installedSettings) { - try { - $json = Get-Content $installedSettings -Raw | ConvertFrom-Json - $json.baseurl = $BaseUrl - $json.apikey = $CollectorKey - $json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8 - Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)" - } - catch { - Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)" - } -} -$exePath = Join-Path $InstallDir "shopdbagent.exe" - -# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files -# extracted from a downloaded zip with a Zone.Identifier alternate data stream; -# SmartScreen reads that and blocks the unsigned exe with "Windows protected -# your PC" the first time it runs, and the operator sees only a "Don't run" -# button. Unblock-File removes the ADS so the smoke test and the tray launch -# start clean. Best-effort: a file with no MotW is a no-op. -Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..." -Get-ChildItem -Path $InstallDir -File | ForEach-Object { - try { Unblock-File -Path $_.FullName -ErrorAction Stop } - catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" } -} - -# 2b. Defender path exclusion for the install dir. Unsigned single-file -# self-contained binaries (and the tray's icon P/Invoke) can trip Defender -# heuristics and get quarantined, which is why the tray dies before showing an -# icon. Excluding the fixed install directory is the least-privilege fix short -# of code-signing. Best-effort: Set-MpPreference is absent on servers without -# Defender and can be blocked by tamper protection; a failure is not fatal. -if (-not $NoDefenderExclusion) { - Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..." - if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) { - try { - Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop - Write-Host " excluded $InstallDir from Defender scanning." - } - catch { - Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)" - Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy." - } - } - else { - Write-Host " Defender cmdlets not present; skipping exclusion." - } -} - -# 3. One-shot smoke test (exact production collection path, then exits). -Write-Host "[3/5] Running one-shot smoke test (--once) ..." -$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow -Write-Host " smoke test exit code: $($proc.ExitCode)" - -$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs" -$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd")) -if (Test-Path $logFile) { - Write-Host " --- last log lines ---" -ForegroundColor DarkGray - Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } -} -if ($proc.ExitCode -ne 0) { - throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service." -} - -# 4. Install the Windows service. Any previous instance was already stopped, -# deleted, and its processes killed in step 0, so this is a clean create. Guard -# against the rare case where the SCM has not finished a pending deletion yet. -Write-Host "[4/5] Installing service $ServiceName ..." -$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue -if ($existing) { - Write-Host " service still present (pending deletion); removing again ..." - if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force } - sc.exe delete $ServiceName | Out-Null - Start-Sleep -Seconds 2 -} -$binPath = '"' + $exePath + '"' -sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null -sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null -sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null - -Start-Service -Name $ServiceName -Write-Host " service started." - -# 5. Register the interactive tray app to launch at user logon (per-machine). -Write-Host "[5/5] Registering tray app for logon ..." -if ($trayInstalled -and (Test-Path $trayInstalled)) { - # Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A - # 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores. - Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` - -Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"') - Write-Host " tray app registered to start at logon (Registry64): $trayInstalled" - - # Start it now so the icon appears without waiting for the next logon. The - # Run key only fires at logon, so a fresh install would otherwise show no - # tray until the operator signs out and back in. - # - # The installer runs elevated, so a direct Start-Process would launch the - # tray ELEVATED too. That is wrong for a tray app (it should run at the - # user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an - # unsigned exe triggers a broker prompt that comes back as Win32 1223 - # "The operation was canceled by the user" - which is exactly what we hit. - # Launching through explorer.exe hands the exe to the already-running user - # shell, which spawns it de-elevated in the interactive session, sidestepping - # the elevation broker entirely. - $interactiveUser = $env:USERNAME - if ($interactiveUser -and $interactiveUser -ne "SYSTEM") { - try { - # explorer.exe re-parents the launch under the user shell at - # medium integrity; it returns immediately regardless of the tray's - # own lifetime, so there is nothing to wait on here. - Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null - Write-Host " tray app started for the current session (de-elevated via explorer)." - } - catch { - Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon." - } - } - else { - Write-Host " no interactive user session; tray will start at next logon." - } -} -else { - Write-Host " tray skipped (headless install)." -} - -Write-Host "`nInstall complete." -ForegroundColor Green -Write-Host "Service '$ServiceName' is running and set to auto-start." -Write-Host "Logs: $logDir" diff --git a/agent/utilities/shopdb-dellupdates.ps1 b/agent/utilities/shopdb-dellupdates.ps1 new file mode 100644 index 00000000..98451c6b --- /dev/null +++ b/agent/utilities/shopdb-dellupdates.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS + ShopDB agent utility: reports pending Dell Command | Update items. + +.DESCRIPTION + Discovered and executed by PowerShellUtilityRunner on the telemetry cadence. + Emits a single JSON object on stdout, which the agent attaches to the report + as `dellupdates`. Never throws and never writes anything but JSON: a failure + here must not break a telemetry run, so every error path still emits a valid + object with a `status` explaining why the list is empty. + + THIS SCRIPT DOES NOT SCAN. It only reads the log a previous scan wrote. The + scan itself is fired by the \ShopDB-DellCommandScan scheduled task, which is + what pins dcu-cli.exe's -outputLog to the path below. Keeping the two apart + is deliberate: a telemetry collector that shelled out to dcu-cli would block + the cadence for minutes and hammer Dell's servers every 15 minutes. + + OUTPUT CONTRACT (keep in sync with plugins/endpoints/plugin.py): + status unknown | ok | error - "unknown" means no scan has ever run, + which is NOT the same as "ok" with an empty list (known clean). + scannedat ISO-8601 UTC timestamp of the scan log's last write, or null. + count total pending updates found. + updates up to $MaxUpdates entries of { name, severity, version }. + truncated true when count exceeded $MaxUpdates. + + Dell's own long description text is deliberately NOT carried: it is + sentence-length marketing prose per update, which would add roughly an order + of magnitude to every report for no operational value. Name plus severity is + what an operator decides on. +#> + +$ErrorActionPreference = 'Stop' + +# Cap the list rather than the payload. A machine that is years behind can have +# dozens of pending updates, and an unbounded list would bloat every report on +# the cadence. Mirrors the MaxReportedConnections precedent in CommandExecutor: +# a truncated answer is more useful than a rejected payload. +$MaxUpdates = 25 + +# Must match the -outputLog path set in the \ShopDB-DellCommandScan task +# definition. ShopDB-initiated scans write HERE rather than to Dell's default +# location, which is what keeps them distinguishable from the corporate-managed +# DCU tasks that also run on these machines. +$ScanLog = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\dellcommand\scan.xml' + +function Write-Result { + param($Status, $ScannedAt, $Count, $Updates, $Truncated) + # Compress so the payload carries no pretty-printing whitespace. Depth 3 + # covers the nested updates array. + [pscustomobject]@{ + status = $Status + scannedat = $ScannedAt + count = $Count + updates = @($Updates) + truncated = [bool]$Truncated + } | ConvertTo-Json -Depth 3 -Compress +} + +try { + if (-not (Test-Path -LiteralPath $ScanLog)) { + # No scan has run yet. This is the normal state for a freshly adopted + # endpoint, not an error, but it must stay distinct from "scanned and + # found nothing" or the UI would claim a machine is up to date when + # nobody has ever checked. + Write-Result -Status 'unknown' -ScannedAt $null -Count 0 -Updates @() -Truncated $false + return + } + + $logFile = Get-Item -LiteralPath $ScanLog + $scannedAt = $logFile.LastWriteTimeUtc.ToString('o') + + [xml]$report = Get-Content -LiteralPath $ScanLog -Raw + + # dcu-cli's scan report shape has varied across DCU versions, so select the + # update nodes by name anywhere in the tree rather than by a fixed path. + $nodes = @($report.SelectNodes('//update')) + if ($nodes.Count -eq 0) { + $nodes = @($report.SelectNodes('//Update')) + } + + $updates = foreach ($node in $nodes) { + # Attribute casing also varies by version; take whichever is present and + # fall back rather than emitting a null-filled row. + $name = $node.name + if (-not $name) { $name = $node.Name } + if (-not $name) { $name = $node.display } + if (-not $name) { continue } + + $severity = $node.urgency + if (-not $severity) { $severity = $node.Urgency } + if (-not $severity) { $severity = $node.criticality } + + $version = $node.version + if (-not $version) { $version = $node.Version } + + [pscustomobject]@{ + name = [string]$name + severity = if ($severity) { [string]$severity } else { 'unknown' } + version = if ($version) { [string]$version } else { $null } + } + } + + $updates = @($updates) + $count = $updates.Count + $truncated = $count -gt $MaxUpdates + if ($truncated) { + $updates = $updates[0..($MaxUpdates - 1)] + } + + Write-Result -Status 'ok' -ScannedAt $scannedAt -Count $count ` + -Updates $updates -Truncated $truncated +} +catch { + # Emit a valid object rather than failing: the runner would log a non-JSON + # utility and drop it, which looks identical to "never ran" on the server. + # An explicit error status tells an operator the scan log exists but could + # not be read, which is a different problem from no scan at all. + Write-Result -Status 'error' -ScannedAt $null -Count 0 -Updates @() -Truncated $false +} diff --git a/docker-compose.backup.yml b/docker-compose.backup.yml new file mode 100644 index 00000000..b89cec69 --- /dev/null +++ b/docker-compose.backup.yml @@ -0,0 +1,175 @@ +# shopdb-flask BACKUP overlay - opt-in, per-site. +# +# WHY a separate overlay: backups are not part of the base stack every site must +# run. A facility turns them on by adding this file to its compose invocation: +# +# docker compose -f docker-compose.yml -f docker-compose.backup.yml \ +# --profile backup up -d +# +# That brings up THREE cooperating pieces: +# - backup-store : a resident, isolated restic REST server (the third +# container). Holds every snapshot in its own volume and is +# reachable ONLY over the compose network - never published +# to the host - so the app can touch backups solely through +# the backup/restore utility, not with a shell or db creds. +# - backup-watcher : a resident poller that actuates the "Back up now" sentinel +# the api drops, running the one-shot against backup-store. +# This is what makes the in-app button produce a real backup +# given the api container cannot run docker. +# - backup : the same one-shot as a manual `run --rm backup` for sites +# that prefer a host scheduler (cron / Task Scheduler). +# +# WHAT each run captures, as one recoverable set: +# - db.sql.gz : mysqldump --single-transaction of the whole DB (users, +# auth, ledger, every plugin table). InnoDB-consistent, +# non-blocking. +# - instance/ : restic snapshot of /app/instance (deduped, encrypted), +# the files MySQL rows point at. +# - manifest.json: core+plugin Alembic revisions and image tag at capture +# time - the stamp that makes a restore a CHECKED operation. +# +# Retention (restic-native): --keep-daily 5 --keep-weekly 4 --keep-monthly 6, +# satisfying "any day in the last 5" plus a cheap long-horizon tail. +# +# DESTINATION defaults to the third container via RESTIC_REPOSITORY: +# third container : rest:http://backup-store:8000/ (default, ships with app) +# AWS S3 : s3:s3.amazonaws.com/shopdb--backups +# Azure Blob : azure:shopdbbackups: +# SMB share : mount the share on the host, point at a local path +# Credentials come from the site's env/secret store, never committed - same +# pattern as the collector key. + +# The THIRD container: an isolated backup store the app can only reach through +# the backup/restore utility, never with a shell or the db credentials. It runs +# restic's REST server over the compose network; the only thing that speaks to +# it is the backup watcher/one-shot via RESTIC_REPOSITORY=rest:http://... . Its +# data lives in its own named volume (backupstore_data), so it is container +# agnostic - later a pull/migration utility can point at this same REST endpoint +# from anywhere without touching the app or the database. +services: + backup-store: + image: restic/rest-server:0.13.0 + profiles: ["backup"] + restart: unless-stopped + environment: + # Private (no public /create) so repos are provisioned by the backup + # client's `restic init`, and append-only OFF here because the same + # utility both writes and prunes; isolation comes from this being the + # only service on the network with access, not from client trust. + DISABLE_AUTHENTICATION: "true" + OPTIONS: "--no-auth" + volumes: + - backupstore_data:/data + # Deliberately NOT published to the host: reachable only over the compose + # network by service name, so nothing outside the stack can touch backups. + expose: + - "8000" + healthcheck: + test: ["CMD", "sh", "-c", "wget -q -O - http://127.0.0.1:8000/ >/dev/null 2>&1 || true"] + interval: 30s + timeout: 5s + retries: 3 + + # Resident watcher: actuates the "Back up now" sentinel the api drops. This is + # the piece that makes the UI button run a real backup, since the api itself + # cannot run docker. It shares the base image with the one-shot and calls the + # same backup.sh. + backup-watcher: + image: shopdb-backup:local + build: + context: . + dockerfile: scripts/backup/Dockerfile.backup + profiles: ["backup"] + restart: unless-stopped + depends_on: + db: + condition: service_healthy + backup-store: + condition: service_started + environment: + # DB access - same credentials the api uses, reaching db over the compose + # network by service name. + MYSQL_HOST: db + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + # Destination is the third container by default: restic's REST backend on + # the isolated backup-store. A site can still override RESTIC_REPOSITORY + # in .env to send offsite instead, but out of the box backups land in the + # dedicated container. + RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/} + RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set} + # Optional cloud creds, passed through when set (empty otherwise). + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-} + AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-} + # Site tag stamped into every snapshot + manifest. + SHOPDB_SITE: ${SHOPDB_SITE:-shopdb} + SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source} + SHOPDB_BACKUPS_ROOT: /backups + BACKUP_POLL_SECONDS: ${BACKUP_POLL_SECONDS:-10} + volumes: + # The watcher services both backup (read) and restore (write) requests, so + # the instance tree is mounted read-write here. Restore has to lay the + # files half of a set back down next to the DB rows that reference it; the + # backup half still only ever reads. The manual one-shot below stays :ro. + - instance_data:/app/instance + # Alembic chain, read-only, so the manifest can stamp the schema revision + # the running code is at. + - ./migrations:/app/migrations:ro + - ./plugins:/app/plugins:ro + # Shared backups volume: holds the fast local db.sql.gz + manifest and the + # .backup-requested sentinel the api drops. The api mounts this same volume + # (see docker-compose.yml) so the UI can enumerate sets and request runs. + - backup_data:/backups + - ./scripts/backup:/opt/backup:ro + entrypoint: ["/opt/backup/watch.sh"] + + # Manual one-shot, unchanged usage: `docker compose ... run --rm backup`. + # Kept for host-scheduler / cron sites that prefer a scheduled pass over the + # in-app button. Shares everything with the watcher but runs once and exits. + backup: + image: shopdb-backup:local + build: + context: . + dockerfile: scripts/backup/Dockerfile.backup + profiles: ["backup"] + restart: "no" + depends_on: + db: + condition: service_healthy + backup-store: + condition: service_started + environment: + MYSQL_HOST: db + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/} + RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-} + AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-} + AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-} + SHOPDB_SITE: ${SHOPDB_SITE:-shopdb} + SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source} + SHOPDB_BACKUPS_ROOT: /backups + volumes: + - instance_data:/app/instance:ro + - ./migrations:/app/migrations:ro + - ./plugins:/app/plugins:ro + - backup_data:/backups + - ./scripts/backup:/opt/backup:ro + entrypoint: ["/opt/backup/backup.sh"] + + # Give the api read/write on the shared backups volume so the UI can list sets + # and drop the "Back up now" sentinel the watcher actuates. This extends the + # api service defined in docker-compose.yml (merged by compose). + api: + volumes: + - backup_data:/backups + +volumes: + backup_data: + backupstore_data: diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md index 9bd27975..24488d9e 100644 --- a/docs/PROJECT-MAP.md +++ b/docs/PROJECT-MAP.md @@ -23,11 +23,11 @@ with `flask plugin upgrade-all`. Both are needed on a deploy. | chain | head | |---|---| -| core | `7d37_import_batches_and_journal` | +| core | `7d41_drop_guest_tools_secrets` | | backups | `backups0003clearlastseen` | | computers | `computers0004policies` | | employees | `employees0002photo` | -| endpoints | `endpoints0011trayversion` | +| endpoints | `endpoints0016dellupdates` | | geenforce | `geenforce0004minlib` | | guides | `guides0001baseline` | | knowledgebase | `knowledgebase0001anchor` | @@ -39,7 +39,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy. | printers | `printers0005observedqueues` | | slides | `slides0001anchor` | | tools | `tools0001securesend` | -| usb | `usb0003scanfields` | +| usb | `usb0004checkinidentity` | | warranty | `warranty0002proof` | ## Bundled plugins (17) @@ -87,9 +87,10 @@ Manifest-less directories under `plugins/` are core frontend surface and always | ADR-015-site-specific-configuration.md | ADR-015: Where a site's own data is allowed to live | ACCEPTED | | ADR-016-credential-delivery.md | ADR-016: Credential delivery to the fleet | ACCEPTED (decided; NOT yet implemented - | | ADR-017-buildings-and-levels.md | ADR-017: Buildings and levels as the map model | ACCEPTED | +| ADR-018-metrology-instruments-are-machines.md | ADR-018: Metrology instruments are machines | ACCEPTED | ## Size -- test functions defined: **1653** (parametrised cases collect higher) -- documented API paths: **361** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) +- test functions defined: **1839** (parametrised cases collect higher) +- documented API paths: **376** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) diff --git a/docs/api-inventory.json b/docs/api-inventory.json index a85441ed..25e6b13b 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -4034,6 +4034,70 @@ "params": "query: search (hostname substring)", "purpose": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here", "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//hardware", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the columns null and the panel hides itself rather than rendering blanks", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/hardware" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//osinfo", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box running\")", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/osinfo" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//storage", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical devices) and are never reconciled across sections", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/storage" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//diskhealth", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage renders it as a section) but kept as a stable read for callers outside the panel renderer", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/diskhealth" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//listeners", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an on-demand command, not standard telemetry", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/listeners" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//mappeddrives", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is signed in is a valid state, not a failure", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/mappeddrives" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//usercontext", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can disagree and the worse status wins, so a half-broken share is never shown healthy", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/usercontext" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//iphistory", + "auth": "endpoints.view", + "params": "path: asset_id", + "purpose": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row moved, NOT when the address was last seen", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/iphistory" } ] }, @@ -4214,5 +4278,82 @@ "example": "curl -X PUT http://localhost:5001/api/endpoints/siteagent/binaries/agent/current -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"version\":\"1.6.2\"}'" } ] + }, + { + "surface": "core-shopdbbackups", + "endpoints": [ + { + "method": "GET", + "path": "/api/shopdbbackups", + "auth": "admin", + "params": "none", + "purpose": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups" + }, + { + "method": "POST", + "path": "/api/shopdbbackups", + "auth": "admin", + "params": "none", + "purpose": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing behind it", + "example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups" + }, + { + "method": "GET", + "path": "/api/shopdbbackups/", + "auth": "admin", + "params": "path: snapshotid", + "purpose": "Return one set's full manifest plus a resolved compatibility verdict", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z" + }, + { + "method": "POST", + "path": "/api/shopdbbackups//restore", + "auth": "admin", + "params": "path: snapshotid", + "purpose": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table", + "example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z/restore" + }, + { + "method": "POST", + "path": "/api/shopdbbackups/finalize-restore", + "auth": "admin", + "params": "none", + "purpose": "Run the post-restore schema upgrade against the just-loaded database. A restore that loaded an older set is NOT complete until this succeeds", + "example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/finalize-restore" + }, + { + "method": "GET", + "path": "/api/shopdbbackups/status", + "auth": "admin", + "params": "none", + "purpose": "Live status of the most recent backup and restore runs, or idle when neither is moving", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/status" + }, + { + "method": "GET", + "path": "/api/shopdbbackups/history", + "auth": "admin", + "params": "none", + "purpose": "Recorded backup and restore run history, newest first", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/history" + }, + { + "method": "GET", + "path": "/api/shopdbbackups/settings", + "auth": "admin", + "params": "none", + "purpose": "Read the backup settings, with secret values masked", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/settings" + }, + { + "method": "PUT", + "path": "/api/shopdbbackups/settings", + "auth": "admin", + "params": "body: backup settings keys", + "purpose": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone", + "example": "curl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"backup_schedule\":\"0 2 * * *\"}' http://localhost:5001/api/shopdbbackups/settings" + } + ] } -] \ No newline at end of file +] diff --git a/docs/openapi.json b/docs/openapi.json index b6bc374d..537904df 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -194,6 +194,9 @@ }, { "name": "endpoints-siteagent" + }, + { + "name": "core-shopdbbackups" } ], "paths": { @@ -18765,9 +18768,8 @@ "plugin-printers" ], "summary": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).", - "description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** jwt-optional\n\n**Params:** ip or fqdn (one required; value matched against communication ipaddress)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```", + "description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** printers.view\n\n**Params:** ip or fqdn (one required; ip matches a communication address, fqdn matches the printer's hostname)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```", "security": [ - {}, { "bearerAuth": [] } @@ -18788,6 +18790,12 @@ "$ref": "#/components/schemas/ErrorEnvelope" } } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." } } } @@ -23332,6 +23340,414 @@ ] } }, + "/api/endpoints/asset/{asset_id}/hardware": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the...", + "description": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the columns null and the panel hides itself rather than rendering blanks\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/hardware\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/osinfo": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box...", + "description": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box running\")\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/osinfo\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/storage": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical...", + "description": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical devices) and are never reconciled across sections\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/storage\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/diskhealth": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage...", + "description": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage renders it as a section) but kept as a stable read for callers outside the panel renderer\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/diskhealth\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/listeners": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an...", + "description": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an on-demand command, not standard telemetry\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/listeners\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/mappeddrives": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is...", + "description": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is signed in is a valid state, not a failure\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/mappeddrives\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/usercontext": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can...", + "description": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can disagree and the worse status wins, so a half-broken share is never shown healthy\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/usercontext\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/iphistory": { + "get": { + "tags": [ + "plugin-endpoints" + ], + "summary": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row...", + "description": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row moved, NOT when the address was last seen\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/iphistory\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/api/scripts": { "get": { "tags": [ @@ -24394,6 +24810,418 @@ } } } + }, + "/api/shopdbbackups": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build", + "description": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing...", + "description": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing behind it\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "none" + } + } + } + } + } + }, + "/api/shopdbbackups/{snapshotid}": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Return one set's full manifest plus a resolved compatibility verdict", + "description": "Return one set's full manifest plus a resolved compatibility verdict\n\n**Auth:** admin\n\n**Params:** path: snapshotid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "snapshotid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/shopdbbackups/{snapshotid}/restore": { + "post": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table", + "description": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table\n\n**Auth:** admin\n\n**Params:** path: snapshotid\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z/restore\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "snapshotid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: snapshotid" + } + } + } + } + } + }, + "/api/shopdbbackups/finalize-restore": { + "post": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Run the post-restore schema upgrade against the just-loaded database. A restore that loaded an older set is NOT...", + "description": "Run the post-restore schema upgrade against the just-loaded database. A restore that loaded an older set is NOT complete until this succeeds\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/finalize-restore\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "none" + } + } + } + } + } + }, + "/api/shopdbbackups/status": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Live status of the most recent backup and restore runs, or idle when neither is moving", + "description": "Live status of the most recent backup and restore runs, or idle when neither is moving\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/status\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/shopdbbackups/history": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Recorded backup and restore run history, newest first", + "description": "Recorded backup and restore run history, newest first\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/history\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/shopdbbackups/settings": { + "get": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Read the backup settings, with secret values masked", + "description": "Read the backup settings, with secret values masked\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/settings\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "put": { + "tags": [ + "core-shopdbbackups" + ], + "summary": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone", + "description": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone\n\n**Auth:** admin\n\n**Params:** body: backup settings keys\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"backup_schedule\":\"0 2 * * *\"}' http://localhost:5001/api/shopdbbackups/settings\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: backup settings keys" + } + } + } + } + } } } } \ No newline at end of file diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index b199931e..07633512 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -3,7 +3,7 @@ a panel as JSON (title, endpoint, render mode, field map); this renders it on any asset detail page, so a plugin adds detail-page UI with no Vue. -->