Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e932ce5c | ||
|
|
75ee3b7c1c | ||
|
|
a46058a4dd | ||
|
|
e2705d732a | ||
|
|
fa6b99bf91 | ||
|
|
83190422c9 | ||
|
|
6e36df841e |
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
@@ -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 <file>`.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
`<site>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> 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<string, string> ResolveScriptPaths()
|
||||
{
|
||||
var resolved = new Dictionary<string, string>(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<string> ApprovedTaskPaths { get; set; } = new();
|
||||
|
||||
public bool IsUsable()
|
||||
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
||||
|
||||
@@ -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<CommandResult> 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<object>();
|
||||
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<string, object?>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<int, string> GetProcessNameSnapshot()
|
||||
{
|
||||
var names = new Dictionary<int, string>();
|
||||
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<CommandResult> 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<CommandResult> 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
|
||||
|
||||
51
agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs
Normal file
51
agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs
Normal file
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<EndpointCollector> logger;
|
||||
private readonly AgentConfig config;
|
||||
private readonly IAgentUtilityRunner? utilities;
|
||||
private readonly EventWatermarkStore? watermarks;
|
||||
private readonly UserContextSpool? userContextSpool;
|
||||
|
||||
private object? lastUserContext;
|
||||
|
||||
public EndpointCollector(ILogger<EndpointCollector> 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<string, object?> 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<object>();
|
||||
|
||||
var names = GetProcessNames();
|
||||
var result = new List<object>();
|
||||
|
||||
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<object>();
|
||||
}
|
||||
}
|
||||
|
||||
// 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<int, string> GetProcessNames()
|
||||
{
|
||||
var names = new Dictionary<int, string>();
|
||||
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<object>();
|
||||
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<EndpointEventRecord>();
|
||||
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<EndpointEventRecord> ReadChannel(
|
||||
string channel, IReadOnlySet<int> 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<EndpointEventRecord>();
|
||||
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 <EventData><Data Name="x">v</Data></>;
|
||||
// some use <UserData> with an arbitrary provider-defined subtree, so both
|
||||
// are walked. Unnamed <Data> 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<string, object>? 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<string, object>();
|
||||
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<int> 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<string, (long? Speed, bool? FullDuplex)> GetLinkState()
|
||||
{
|
||||
var byMac = new Dictionary<string, (long?, bool?)>(
|
||||
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<string>();
|
||||
|
||||
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<object>();
|
||||
|
||||
// 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)
|
||||
|
||||
132
agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs
Normal file
132
agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs
Normal file
@@ -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<EventWatermarkStore> logger;
|
||||
private readonly Dictionary<string, long> watermarks;
|
||||
|
||||
public EventWatermarkStore(ILogger<EventWatermarkStore> 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<string, long> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(storePath))
|
||||
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, long>>(
|
||||
File.ReadAllText(storePath));
|
||||
return loaded is null
|
||||
? new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, long>(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<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, FileLogger> 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:
|
||||
// "<yyyyMMdd>-<prefix>.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 "<prefix>-<date>"
|
||||
// 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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs
Normal file
159
agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs
Normal file
@@ -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<string> DefaultApprovedTaskPaths { get; } = new[]
|
||||
{
|
||||
@"\ReportIP",
|
||||
@"\SFLD-ApplyDSCConfig",
|
||||
@"\SFLD - Consume Credentials",
|
||||
@"\ShopDB-DellCommandScan",
|
||||
@"\ShopDB-DellCommandUpdate",
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<string> approvedPaths;
|
||||
|
||||
public ScheduledTaskPolicy(IEnumerable<string>? approvedPathsOverride = null)
|
||||
{
|
||||
var paths = (approvedPathsOverride ?? Enumerable.Empty<string>())
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
<!-- EventWaitHandleAcl: creating the user-context refresh event with an
|
||||
explicit DACL. The service creates it as LocalSystem, and without a
|
||||
granted ACE the unelevated tray cannot open it. -->
|
||||
<PackageReference Include="System.Threading.AccessControl" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
128
agent/ShopDbEndpointAgent.Core/TcpTableReader.cs
Normal file
128
agent/ShopDbEndpointAgent.Core/TcpTableReader.cs
Normal file
@@ -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<TcpRow> GetTcpTable()
|
||||
{
|
||||
var rows = new List<TcpRow>();
|
||||
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<MIB_TCPROW_OWNER_PID>();
|
||||
var cursor = IntPtr.Add(buffer, 4);
|
||||
|
||||
for (var index = 0; index < entryCount; index++)
|
||||
{
|
||||
var row = Marshal.PtrToStructure<MIB_TCPROW_OWNER_PID>(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<TcpRow> 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);
|
||||
}
|
||||
@@ -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<TelemetryWorker> 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<TelemetryWorker> 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(
|
||||
|
||||
362
agent/ShopDbEndpointAgent.Core/UserContextProbe.cs
Normal file
362
agent/ShopDbEndpointAgent.Core/UserContextProbe.cs
Normal file
@@ -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<UserContextProbe> logger;
|
||||
|
||||
public UserContextProbe(ILogger<UserContextProbe> 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
179
agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs
Normal file
179
agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs
Normal file
@@ -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<T>: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
agent/ShopDbEndpointAgent.Core/UserContextSpool.cs
Normal file
389
agent/ShopDbEndpointAgent.Core/UserContextSpool.cs
Normal file
@@ -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<UserContextSpool> logger;
|
||||
|
||||
public UserContextSpool(ILogger<UserContextSpool> 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<string, object?>? 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<Dictionary<string, object?>>(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<string, object?>? 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<Dictionary<string, object?>>(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<string, object?> 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<string, object?> 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<string, object?> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>());
|
||||
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;
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
|
||||
<!-- ScheduledTaskPolicy is pure string logic with no filesystem state to
|
||||
plant, so unlike LaunchPathPolicy it would run anywhere. It is linked
|
||||
here anyway to keep both allowlist policies provable from one harness. -->
|
||||
<Compile Include="../ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs" Link="ScheduledTaskPolicy.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,11 +8,70 @@
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
|
||||
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
|
||||
<!-- 1.1.0: the tray now collects session-scoped user context. It invokes
|
||||
shopdbagent.exe with the collect-usercontext verb on a timer (once
|
||||
shortly after logon, then periodically) and the agent service reads the
|
||||
resulting spool file. The tray owns this because it is the only ShopDB
|
||||
process already running as the operator: mapped drives, user-owned
|
||||
listeners, and input idle time do not exist in the service's session 0.
|
||||
Requires agent 1.8.0 or newer: an older agent does not recognize the
|
||||
verb and falls through to starting a full host, so the tray and agent
|
||||
must be deployed together. -->
|
||||
<!-- 1.0.0: first explicitly stamped tray version. The tray and the agent
|
||||
service version independently (they are separate exes and can be pushed
|
||||
apart), so the tray carries its own <Version> and the agent reports it
|
||||
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
|
||||
<Version>1.0.0</Version>
|
||||
<!-- 1.1.1: the tray now logs. Every user-context failure path was silent -
|
||||
a missing shopdbagent.exe returned bare, and the probe launch was
|
||||
wrapped in an empty catch - so "no mapped drives" produced no evidence
|
||||
anywhere on the machine. The tray writes
|
||||
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log (the operator's
|
||||
own profile; it cannot write to ProgramData or Program Files) covering
|
||||
tray startup, agent-exe resolution, probe launch and probe exit code. -->
|
||||
<!-- 1.2.0: the tray stops claiming success it did not verify. The probe
|
||||
completion message printed a hardcoded spool path regardless of what
|
||||
happened, so a probe that exited 0 without writing anything read as a
|
||||
clean collection; it now reports the real pinned path and distinguishes
|
||||
spool-present, exit-0-but-no-file, and non-zero exit. Startup probe
|
||||
delay is chosen from session uptime: the 2-minute delay still applies at
|
||||
logon (drive mappings are not established yet), but a tray started into
|
||||
an already-running session - which is what an installer does - probes
|
||||
after 15 seconds instead of leaving a fresh install blind for the full
|
||||
30-minute cycle. A log sink that cannot open its directory raises a
|
||||
one-time balloon, since it cannot report its own failure through the
|
||||
log. Requires agent 1.9.0: the two carry the shared pinned spool path
|
||||
and must be deployed together. -->
|
||||
<!-- 1.3.0: the tray is no longer the authoritative user-context collector.
|
||||
The 'ShopDB user-context probe' scheduled task now owns collection,
|
||||
because it is the only mechanism that runs in the operator's own logon
|
||||
session at RunLevel Limited; the tray keeps its own probe cadence as
|
||||
belt-and-braces and writes the same spool. The installer no longer
|
||||
starts the tray directly (explorer.exe de-elevated it but did not
|
||||
change ACCOUNT, so an admin install produced an admin tray whose
|
||||
probe saw the wrong drive table). Autostart at logon is the Run key;
|
||||
install-time startup on a machine someone is already signed in to is
|
||||
a transient 'ShopDB tray first launch' scheduled task, which is the
|
||||
only way to enter the operator's existing session unelevated. Probe
|
||||
exit code 2 - the agent refusing to collect while elevated - is
|
||||
reported by name instead of as a bare code. Requires agent 1.10.1. -->
|
||||
<!-- 1.4.0: the tray answers on-demand refresh requests. A background
|
||||
listener waits on Global\ShopDbEndpointAgent.UserContextRefresh and
|
||||
runs the probe when the service asks, which is what makes a WebUI
|
||||
"Refresh Status" click produce current mapped drives instead of the
|
||||
last cadence's answer - the service cannot collect this itself at any
|
||||
price, since drive letters live on the operator's logon token, so the
|
||||
request has to be serviced HERE. Each probe is marshalled onto the UI
|
||||
thread: the cadence timer and a service request could otherwise both
|
||||
pass the in-flight guard and race the same spool file. "Send Status"
|
||||
now awaits a probe before invoking the once verb, because that verb
|
||||
reads the spool and would otherwise publish the previous probe's
|
||||
answer to an operator who just changed something and is watching
|
||||
for it. The probe
|
||||
body is one shared method used by all three callers rather than three
|
||||
copies of the exit-code handling. A tray that cannot open the channel
|
||||
logs and keeps its timer, so the failure mode is "refresh is not
|
||||
immediate", not "session data stops working". Requires agent 1.11.0. -->
|
||||
<Version>1.4.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -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\<datetime>-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<bool> 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();
|
||||
}
|
||||
|
||||
@@ -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<UserContextProbe>();
|
||||
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<ILogger<PowerShellUtilityRunner>>()));
|
||||
}
|
||||
|
||||
// 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<ILogger<EventWatermarkStore>>()));
|
||||
|
||||
// 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<ILogger<UserContextSpool>>()));
|
||||
|
||||
// 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<ILogger<UserContextRefreshChannel>>()));
|
||||
|
||||
services.AddSingleton<ICollector>(sp => new EndpointCollector(
|
||||
sp.GetRequiredService<ILogger<EndpointCollector>>(),
|
||||
config,
|
||||
sp.GetService<IAgentUtilityRunner>()));
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<EventWatermarkStore>(),
|
||||
sp.GetRequiredService<UserContextSpool>()));
|
||||
|
||||
services.AddSingleton(sp => new CollectorClient(
|
||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
@@ -206,7 +328,9 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<CollectSignal>()));
|
||||
sp.GetRequiredService<CollectSignal>(),
|
||||
sp.GetRequiredService<UserContextSpool>(),
|
||||
sp.GetRequiredService<UserContextRefreshChannel>()));
|
||||
|
||||
// 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}");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,88 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.6.3: operator issue reports stage under the interactive operator's
|
||||
<!-- 1.10.1: the collect-usercontext verb hides its console window. The agent
|
||||
is a console exe, which the tray used to mask by launching the probe with
|
||||
CreateNoWindow; the scheduled task runs the exe directly in the operator's
|
||||
session, so a black window appeared on every 30-minute collection. -->
|
||||
<!-- 1.10.0: user context is collected by a scheduled task in the operator's
|
||||
own logon session, not by a process the elevated installer launches.
|
||||
Drive letters are per logon token: an elevated probe enumerates a
|
||||
different, usually empty, drive table, so an install performed by an
|
||||
admin produced a spool reporting every mapped drive as Unavailable while
|
||||
the operator's own session showed OK. The installer no longer starts the
|
||||
tray via explorer.exe (which de-elevates but does NOT change account) and
|
||||
instead registers 'ShopDB user-context probe' against BUILTIN\Users at
|
||||
RunLevel Limited, verifies it by reading the task back, and records the
|
||||
outcome in the install ledger. The collect-usercontext verb now refuses
|
||||
to run elevated (exit 2) rather than writing a confident wrong answer,
|
||||
the probe stamps its token elevation, and the service rejects any spool
|
||||
stamped elevated. Probe runs log the drive and listener counts. -->
|
||||
<!-- 1.9.0: the spool is finally written where the service looks for it. The
|
||||
probe script resolved its output directory from $env:USERPROFILE while
|
||||
the reader used the pinned C:\Users\ShopFloor path; under a brokered or
|
||||
re-parented tray launch those differ, so the probe reported success and
|
||||
wrote the file somewhere the service never read. Both sides now derive
|
||||
the path from one helper (UserContextSpool.PinnedResultPath). Collect()
|
||||
verifies the file exists on disk before reporting success, because a
|
||||
zero exit code from powershell.exe proved nothing about the write.
|
||||
Minor rather than patch because the spool handoff itself changed shape
|
||||
(AGENT-CONTRACT.md 5): the spool is now PERSISTED and overwritten each
|
||||
run rather than consumed on read. The collector cache is in-memory only,
|
||||
so consume-on-read plus a service restart blanked mapped drives until the
|
||||
next tray cycle. Max age tightened 24h -> 90m now that staleness, not
|
||||
deletion, is the retirement mechanism. A spool whose username stamp is
|
||||
absent or not ShopFloor is rejected outright - wrong-session drive data
|
||||
is worse than none.
|
||||
The collect-usercontext branch moved to the top of Program.cs: it used to
|
||||
open the SYSTEM-owned log directory, register an EventLog source, and
|
||||
read CollectorKey out of HKLM before discovering it only needed to write
|
||||
a file into the caller's own profile - all as the operator, and all
|
||||
exactly what an EPM broker intercepts. Log filenames unified on
|
||||
<date>-<component>.log across service, tray, and installer. A log sink
|
||||
that cannot open its directory now says so on stderr instead of
|
||||
discarding every line unread. Pairs with tray 1.2.0. -->
|
||||
<!-- 1.8.1: user-context logging and spool location are split from the
|
||||
service's. The probe runs as the signed-in operator but logged into
|
||||
%ProgramData%\ShopDB\EndpointAgent\logs, which is SYSTEM-owned and not
|
||||
writable from a user session; the file sink swallows that failure, so a
|
||||
probe that failed and a probe that never ran looked identical - no log
|
||||
line either way. The probe and the tray now log to
|
||||
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log, which the
|
||||
operator owns. The spool path is pinned to C:\Users\ShopFloor\ShopDB
|
||||
instead of being resolved through WTSQueryUserToken (a privileged call
|
||||
endpoint protection blocks, after which writer and reader disagreed
|
||||
silently about the location). The service now CONSUMES the spool -
|
||||
removing it after a successful parse - so a dead tray stops re-reporting
|
||||
stale drives instead of pinning them forever. The probe itself is
|
||||
launched with -EncodedCommand rather than a staged .ps1 run via -File:
|
||||
a quarantined or missing temp script makes powershell.exe print usage
|
||||
and exit -196608 without running a statement. -->
|
||||
<!-- 1.8.0: user-context collection is inverted. The service no longer
|
||||
descends into the operator's session with CreateProcessAsUser to run the
|
||||
probe; that path needs to duplicate a token, is what endpoint protection
|
||||
watches for, and produced no spool file at all on a live endpoint. The
|
||||
tray, which is already running as the operator, now invokes
|
||||
shopdbagent.exe with the collect-usercontext verb on its own cadence and
|
||||
the service only reads the spool. Requires the 1.1.0 tray: an older tray
|
||||
the new verb, so usercontext stays null until the tray is updated.
|
||||
Spool freshness moved from delete-around-read to the in-file collectedat
|
||||
stamp with a max age, because writer and reader are now independent
|
||||
processes and a delete-then-read would race the tray and discard good
|
||||
results. -->
|
||||
<!-- 1.7.0: curated events are harvested against a persisted per-channel
|
||||
Windows record-id watermark and carry eventrecordid, so one real event
|
||||
is reported and stored exactly once instead of once per cadence tick
|
||||
for as long as it stayed in the newest-25 window. Adds WER 1000/1002,
|
||||
event XML fields, listening ports, an on-demand network_connections
|
||||
command, pendingreboot, disk freepercent, and populates cpupercent/
|
||||
memorypercent/idleseconds (previously declared but always null).
|
||||
Session-scoped data (mapped drives, user-owned listeners, idle time)
|
||||
comes from a probe run in the interactive user's session via a spool
|
||||
file, because none of it exists in the agent's LocalSystem session 0.
|
||||
run_script user-context waits are now bounded; the previous Infinite
|
||||
wait could pin the command worker on a hung script.
|
||||
1.6.3: operator issue reports stage under the interactive operator's
|
||||
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
|
||||
instead of %TEMP%, which on locked-down endpoints redirected to another
|
||||
user's AppData\Local\Temp and left the SYSTEM-context report upload
|
||||
@@ -50,7 +131,26 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.6.3</Version>
|
||||
<!-- 1.11.0: on-demand user-context refresh. "Refresh Status" (collect_now)
|
||||
and the tray's "Send Status" re-sent whatever was already in the spool,
|
||||
so a drive the operator had just unmapped kept reporting OK until the
|
||||
next 30-minute probe and no amount of clicking changed it. The service
|
||||
still does not - and must not - collect session data itself; it signals
|
||||
Global\ShopDbEndpointAgent.UserContextRefresh and waits up to 12s for the
|
||||
spool's collectedat stamp to ADVANCE before collecting, which is the only
|
||||
proof the file in front of it answers this request and not the previous
|
||||
one. The wait is bounded and non-fatal by design: telemetry is the
|
||||
liveness signal, so a timeout reports slightly stale session data rather
|
||||
than skipping the report. UserContextSpool.TryReadCollectedAt reads the
|
||||
stamp outside the usability gates, so a spool rejected as expired or
|
||||
wrong-account does not burn the full timeout every cycle. Minor because
|
||||
the spool handoff gains a second channel (AGENT-CONTRACT.md 5) and
|
||||
because the installer now registers the probe task to the ShopFloor USER
|
||||
rather than BUILTIN\Users: a group-principal task cannot be started by a
|
||||
member of that group, so Start-ScheduledTask returned access denied and
|
||||
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
|
||||
is the side that listens, so an older tray simply never answers. -->
|
||||
<Version>1.11.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -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://<host>:<port>' 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 <path> 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"
|
||||
119
agent/utilities/shopdb-dellupdates.ps1
Normal file
119
agent/utilities/shopdb-dellupdates.ps1
Normal file
@@ -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
|
||||
}
|
||||
@@ -101,6 +101,17 @@
|
||||
</rewrite>
|
||||
SHOPDB-CLIENTIP-END -->
|
||||
|
||||
<security>
|
||||
<requestFiltering>
|
||||
<!-- IIS request filtering caps a request body at 30,000,000 bytes by
|
||||
default and answers 413 before waitress sees it. The agent package
|
||||
upload accepts 128 MB (MAX_CONTENT_LENGTH is 256 MB), and the tray
|
||||
binary alone is ~69 MB, so the IIS cap has to be raised to match or
|
||||
every agent upload fails on Windows while working under Docker. -->
|
||||
<requestLimits maxAllowedContentLength="268435456" />
|
||||
</requestFiltering>
|
||||
</security>
|
||||
|
||||
</system.webServer>
|
||||
|
||||
<!--
|
||||
|
||||
175
docker-compose.backup.yml
Normal file
175
docker-compose.backup.yml
Normal file
@@ -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-<site>-backups
|
||||
# Azure Blob : azure:shopdbbackups:<site>
|
||||
# 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:
|
||||
@@ -49,6 +49,7 @@ Recorded in the comment block in `shopdb/__init__.py`:
|
||||
| 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) |
|
||||
| 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `monitors` / `software` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) |
|
||||
| 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) |
|
||||
| 0.31.0 | The asset-panel vocabulary a plugin may declare grew four optional things: two more tab groups, `serviceprocess` and `reports`, beside the 0.29.0 set; a `format: 'datetime'` on a column or field, so a panel stops shipping pre-formatted date strings the browser cannot re-localise; a `map.body` key, so a keyvalue field can name where its value lives in a nested payload; and a `tabs` section that carries `columns`/`rows`/`empty` and renders as a table, so one card can hold two tables that share no key (volume capacity and physical drive health are the case). All optional and all resolved server-side when absent, so a plugin declaring none of them is unaffected | additive surface (minor) |
|
||||
|
||||
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
|
||||
0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded
|
||||
|
||||
@@ -12,7 +12,7 @@ never by editing this file.
|
||||
| series | value | governed by |
|
||||
|---|---|---|
|
||||
| product `__version__` | `0.12.0` | ADR-007 |
|
||||
| plugin contract `__contract_version__` | `0.30.0` | ADR-002 |
|
||||
| plugin contract `__contract_version__` | `0.31.0` | ADR-002 |
|
||||
|
||||
They move independently. A contract bump is not a release.
|
||||
|
||||
@@ -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: **1854** (parametrised cases collect higher)
|
||||
- documented API paths: **376** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
|
||||
|
||||
@@ -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/<asset_id>/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/<asset_id>/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/<asset_id>/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/<asset_id>/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/<asset_id>/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/<asset_id>/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/<asset_id>/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/<asset_id>/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/<snapshotid>",
|
||||
"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/<snapshotid>/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": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does NOT run the upgrade in the request: that watcher runs it out of band, so a restore completes whether or not the browser that started it is still open, and Alembic cannot be killed by the worker timeout mid-chain",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does...",
|
||||
"description": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does NOT run the upgrade in the request: that watcher runs it out of band, so a restore completes whether or not the browser that started it is still open, and Alembic cannot be killed by the worker timeout mid-chain\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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. -->
|
||||
<template v-for="panel in groupedPanels" :key="panel.id">
|
||||
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
|
||||
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': spansFullWidth(panel) }">
|
||||
<!-- collapsible panels get a clickable header with a caret and, when the
|
||||
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||
row (a stopped service or a warning/critical event). --><h3
|
||||
@@ -52,7 +52,20 @@
|
||||
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
|
||||
<div class="pap-item-top">
|
||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
||||
<span v-if="mapBadge(panel, item)" class="status-badge"
|
||||
<!-- On a row carrying a body the severity badge IS the expander:
|
||||
it already says what the row is, and a second control beside
|
||||
it only repeated that. Rows with no body keep the plain
|
||||
non-interactive badge. -->
|
||||
<button
|
||||
v-if="mapBadge(panel, item) && mapBody(panel, item)"
|
||||
type="button"
|
||||
class="status-badge pap-badge-toggle"
|
||||
:style="colorStyle(mapBadge(panel, item).color)"
|
||||
:aria-expanded="isBodyOpen(panel, index) ? 'true' : 'false'"
|
||||
:title="isBodyOpen(panel, index) ? 'Hide details' : 'Show details'"
|
||||
@click="toggleBody(panel, index)"
|
||||
>{{ mapBadge(panel, item).label }}</button>
|
||||
<span v-else-if="mapBadge(panel, item)" class="status-badge"
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</span>
|
||||
@@ -68,6 +81,9 @@
|
||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||
:class="{ mono: m.mono }">{{ m.text }}</span>
|
||||
</div>
|
||||
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
|
||||
{{ mapBody(panel, item) }}
|
||||
</div>
|
||||
</div>
|
||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
@@ -104,7 +120,42 @@
|
||||
@click="setTab(panel, si)"
|
||||
>{{ section.label }}</button>
|
||||
</div>
|
||||
<div class="pap-kv">
|
||||
<!-- A section carrying columns renders as a table; otherwise it is the
|
||||
original label/value grid. Both shapes coexist in one panel. -->
|
||||
<div
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
|
||||
class="pap-table-wrap"
|
||||
>
|
||||
<table
|
||||
v-if="((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
|
||||
class="pap-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
|
||||
:key="col.key"
|
||||
>{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, ri) in (tabSections(panel)[activeTab(panel)] || {}).rows || []"
|
||||
:key="ri"
|
||||
>
|
||||
<td
|
||||
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
|
||||
:key="col.key"
|
||||
>{{ cell(row, col) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
v-if="!((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
|
||||
class="pap-empty"
|
||||
>{{ (tabSections(panel)[activeTab(panel)] || {}).empty || 'Nothing reported.' }}</div>
|
||||
</div>
|
||||
<div v-else class="pap-kv">
|
||||
<div
|
||||
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
||||
:key="fi"
|
||||
@@ -126,7 +177,10 @@
|
||||
|
||||
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
||||
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
||||
<table class="pap-table">
|
||||
<!-- Headers are suppressed when there are no rows. A bare header row
|
||||
over an empty body reads as a broken card; the declared empty
|
||||
message says which of "nothing here" or "nothing reported" it is. -->
|
||||
<table v-if="rows(panel).length" class="pap-table">
|
||||
<thead>
|
||||
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
||||
</thead>
|
||||
@@ -136,6 +190,9 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="pap-empty">
|
||||
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- badge: a row of colored badges -->
|
||||
@@ -240,9 +297,9 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction, facets, facetedRows, formatValue,
|
||||
rowAction, facets, facetedRows, formatValue, spansFullWidth,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
// Operator report screenshots are served by a JWT-gated route, so a bare
|
||||
@@ -365,12 +422,39 @@ function visibleRows(panel) {
|
||||
return facetedRows(panel, facetSelection.value[panel.id])
|
||||
}
|
||||
|
||||
// Which row bodies are expanded, keyed by panel id and row index. Closed on
|
||||
// every panel refresh: the row at index 3 after a refresh is not necessarily
|
||||
// the row that was opened, so keeping it open would expand an unrelated event.
|
||||
const bodyOpen = ref({})
|
||||
|
||||
function bodyKey(panel, index) {
|
||||
return `${panel.id}:${index}`
|
||||
}
|
||||
|
||||
function isBodyOpen(panel, index) {
|
||||
return !!bodyOpen.value[bodyKey(panel, index)]
|
||||
}
|
||||
|
||||
function toggleBody(panel, index) {
|
||||
const key = bodyKey(panel, index)
|
||||
bodyOpen.value = { ...bodyOpen.value, [key]: !bodyOpen.value[key] }
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
assetid: { type: [Number, String], default: null },
|
||||
// Which detail-page tab to render. Null renders every panel, which is what
|
||||
// the detail pages that have not adopted tabs still do - so this stays
|
||||
// additive and no existing page changes behaviour.
|
||||
group: { type: String, default: null },
|
||||
// Render exactly these panel ids, ignoring group. For lifting one card out
|
||||
// of its tab and showing it somewhere else - Network Interfaces is pinned to
|
||||
// the top of Details while still belonging to monitors. Kept as a panel-id
|
||||
// filter rather than a new group because a group would need the four-file
|
||||
// vocabulary change AND would add a tab nobody asked for.
|
||||
only: { type: Array, default: null },
|
||||
// The other half of `only`: the group instance must drop what was lifted out
|
||||
// of it, or the card renders twice on two different tabs.
|
||||
exclude: { type: Array, default: null },
|
||||
})
|
||||
|
||||
const panels = ref([])
|
||||
@@ -386,14 +470,19 @@ const panels = ref([])
|
||||
// 'actions', this build had no such tab, and the card was simply gone with
|
||||
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
|
||||
// Fall back the same way the server does rather than drop it.
|
||||
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
|
||||
const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'reports', 'software', 'activity']
|
||||
function groupOf(panel) {
|
||||
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
|
||||
}
|
||||
|
||||
const groupedPanels = computed(() =>
|
||||
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
|
||||
)
|
||||
const groupedPanels = computed(() => {
|
||||
if (props.only) return panels.value.filter((p) => props.only.includes(p.id))
|
||||
let list = props.group
|
||||
? panels.value.filter((p) => groupOf(p) === props.group)
|
||||
: panels.value
|
||||
if (props.exclude) list = list.filter((p) => !props.exclude.includes(p.id))
|
||||
return list
|
||||
})
|
||||
|
||||
// Which groups actually have a panel with data, so a parent can hide a tab
|
||||
// rather than offering an empty one.
|
||||
@@ -525,6 +614,7 @@ async function load() {
|
||||
panels.value = []
|
||||
revokeReportImageUrls()
|
||||
closeReport()
|
||||
bodyOpen.value = {}
|
||||
if (!props.assetid) return
|
||||
let declared
|
||||
try {
|
||||
@@ -562,6 +652,31 @@ defineExpose({ refresh: load, populatedGroups })
|
||||
.pap-title { font-weight: 600; color: var(--text); }
|
||||
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
||||
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
|
||||
/* The badge doubles as the expander, so it needs to look pressable without
|
||||
becoming a second style of badge: same pill, just a pointer and a hover
|
||||
lift. margin-left:auto pins it right even on a row that also has an
|
||||
action button, where space-between alone would centre it. */
|
||||
.pap-badge-toggle {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.pap-badge-toggle:hover { filter: brightness(1.1); }
|
||||
.pap-badge-toggle:focus-visible { outline: 2px solid var(--primary, #4181ff); outline-offset: 2px; }
|
||||
/* pre-wrap, not normal: the message keeps the line breaks the event was
|
||||
written with, which is what makes a multi-line fault readable. */
|
||||
.pap-body {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-light);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||
|
||||
.pap-facets {
|
||||
|
||||
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
51
frontend/src/components/emptyTablePanel.spec.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// A `table` panel with no rows used to render a bare header row over an empty
|
||||
// body, which reads as a broken card rather than as an answer. `list` had
|
||||
// honoured panel.empty for a long time; `table` had not, for no reason anybody
|
||||
// wrote down. Found on a live page where Network Interfaces and Listening
|
||||
// Ports both showed column headers and nothing else.
|
||||
//
|
||||
// Asserted against the SOURCE because the condition lives in the template, not
|
||||
// in an exported helper - the same reason panelGroups.spec.js reads source.
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, 'PluginAssetPanels.vue'), 'utf8',
|
||||
)
|
||||
|
||||
function tableBlock() {
|
||||
const start = source.indexOf("panel.render === 'table'")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
// Up to the next render branch, so we only look at the table path.
|
||||
const end = source.indexOf("panel.render === 'badge'", start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('an empty table panel', () => {
|
||||
it('does not render the table element when there are no rows', () => {
|
||||
expect(tableBlock()).toMatch(/<table[^>]*v-if="rows\(panel\)\.length"/)
|
||||
})
|
||||
|
||||
it('falls back to the panel-declared empty message', () => {
|
||||
expect(tableBlock()).toContain('panel.empty')
|
||||
})
|
||||
|
||||
it('keeps a default message for a panel that declares none', () => {
|
||||
// An empty card with no words in it is indistinguishable from a card that
|
||||
// failed to load.
|
||||
expect(tableBlock()).toMatch(/panel\.empty \|\| '[^']+'/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('an empty table SECTION inside a tabs panel', () => {
|
||||
it('also suppresses its header row', () => {
|
||||
// The section path was added with the Storage merge and had the same bug.
|
||||
const start = source.indexOf("panel.render === 'tabs'")
|
||||
const end = source.indexOf("panel.render === 'keyvalue'", start)
|
||||
const block = source.slice(start, end)
|
||||
expect(block).toMatch(/<table\s+v-if="\(\(tabSections\(panel\)/)
|
||||
})
|
||||
})
|
||||
124
frontend/src/components/panelGroups.spec.js
Normal file
124
frontend/src/components/panelGroups.spec.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// The group vocabulary is split across four files and there is no runtime that
|
||||
// checks they agree. When they disagree the failure is SILENT: a panel in a
|
||||
// group nothing renders is fetched, held, and shown on no tab, with nothing
|
||||
// logged. That has eaten cards twice - tracked services moving out of
|
||||
// 'actions', and the 'metrics' to 'monitors' rename.
|
||||
//
|
||||
// So this reads the sources and asserts the vocabularies match. It is a source
|
||||
// check rather than a behaviour check because RENDERED_GROUPS is a module-local
|
||||
// constant in the .vue file with no export to assert against.
|
||||
|
||||
function read(relative) {
|
||||
// .href, not the URL object: the jsdom test environment supplies its own
|
||||
// URL global, and node's fileURLToPath rejects a foreign instance with
|
||||
// "must be ... an instance of URL. Received an instance of URL".
|
||||
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
|
||||
}
|
||||
|
||||
function renderedGroups() {
|
||||
const source = read('./PluginAssetPanels.vue')
|
||||
const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/)
|
||||
if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue')
|
||||
return match[1].split(',')
|
||||
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function pcDetailSource() {
|
||||
return read('../../../plugins/computers/frontend/views/PCDetail.vue')
|
||||
}
|
||||
|
||||
// The tabs that actually HOST panels - those where PCDetail mounts a
|
||||
// PluginAssetPanels for the group. A tab is only bound by the vocabulary if a
|
||||
// panel can land in it. Corporate Tools is the first tab that hosts none: it is
|
||||
// permission-gated and renders a fixed action list, so RENDERED_GROUPS has
|
||||
// nothing to say about it and requiring it there would make 'corptools' a legal
|
||||
// panel group with no panel host - fetched, held, rendered nowhere, which is
|
||||
// the very failure this file exists to catch.
|
||||
function panelHostedGroups(source) {
|
||||
return [...source.matchAll(/group="([a-z]+)"/g)].map((match) => match[1])
|
||||
}
|
||||
|
||||
describe('panel group vocabulary', () => {
|
||||
it('renders a group for every panel-backed tab PCDetail offers', () => {
|
||||
const groups = renderedGroups()
|
||||
const source = pcDetailSource()
|
||||
const hosted = panelHostedGroups(source)
|
||||
const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((key) => hosted.includes(key))
|
||||
|
||||
expect(tabKeys.length).toBeGreaterThan(0)
|
||||
for (const key of tabKeys) {
|
||||
expect(groups).toContain(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('never hosts panels in a group this build does not render', () => {
|
||||
// The mirror of that exemption, so opting a tab out of panels cannot become
|
||||
// a loophole: the moment a tab mounts a PluginAssetPanels, its group MUST
|
||||
// be rendered, or the panels are fetched and dropped with nothing logged.
|
||||
const groups = renderedGroups()
|
||||
const hosted = panelHostedGroups(pcDetailSource())
|
||||
|
||||
expect(hosted.length).toBeGreaterThan(0)
|
||||
for (const group of hosted) {
|
||||
expect(groups).toContain(group)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every rendered group somewhere to actually show', () => {
|
||||
// A group in RENDERED_GROUPS with no v-show block is the mirror failure:
|
||||
// the panel is kept rather than dropped, but still appears nowhere.
|
||||
const source = pcDetailSource()
|
||||
for (const group of renderedGroups()) {
|
||||
expect(source).toContain(`activegroup === '${group}'`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the service/process split wired end to end', () => {
|
||||
// Pins the specific split rather than only the general rule, so removing
|
||||
// the tab is a deliberate edit to this test and not a silent regression.
|
||||
const source = pcDetailSource()
|
||||
|
||||
expect(renderedGroups()).toContain('serviceprocess')
|
||||
expect(source).toContain("key: 'serviceprocess'")
|
||||
expect(source).toContain("group=\"serviceprocess\"")
|
||||
})
|
||||
|
||||
it('still renders the system monitors group after the rename', () => {
|
||||
// The rename is label-only; the group key must not drift with it.
|
||||
expect(renderedGroups()).toContain('monitors')
|
||||
expect(pcDetailSource()).toContain("label: 'System Monitors'")
|
||||
})
|
||||
|
||||
it('agrees with the server vocabulary that resolves groups first', () => {
|
||||
// The fourth file this comment block claims to cover, and the one that was
|
||||
// NOT actually being checked. asset_panels() rewrites any group missing
|
||||
// from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the
|
||||
// frontend can be perfectly consistent with itself and still show nothing:
|
||||
// that is exactly how the Service/Process tab went missing after Phase 3.
|
||||
const source = read('../../../shopdb/core/api/pluginui.py')
|
||||
const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/)
|
||||
if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py')
|
||||
const serverGroups = match[1].split(',')
|
||||
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
|
||||
.filter(Boolean)
|
||||
|
||||
for (const group of renderedGroups()) {
|
||||
expect(serverGroups).toContain(group)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps operator reports in a tab of its own', () => {
|
||||
const source = pcDetailSource()
|
||||
|
||||
expect(renderedGroups()).toContain('reports')
|
||||
expect(source).toContain("key: 'reports'")
|
||||
expect(source).toContain('group="reports"')
|
||||
})
|
||||
})
|
||||
211
frontend/src/components/pinnedPanels.spec.js
Normal file
211
frontend/src/components/pinnedPanels.spec.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Network Interfaces is DECLARED in the monitors group but RENDERED at the top
|
||||
// of the Details tab. That is done with a panel-id filter (`only`) rather than
|
||||
// a new group, because a new group would need the four-file vocabulary change
|
||||
// and would add a tab nobody asked for.
|
||||
//
|
||||
// The pair is the fragile part: `only` lifts the panel out, and `exclude` must
|
||||
// drop it from the group instance it was lifted from. Miss the second half and
|
||||
// the same card renders on two tabs, which looks like duplicated data rather
|
||||
// than a layout bug.
|
||||
|
||||
function read(relative) {
|
||||
// .href, not the URL object: the jsdom test environment supplies its own
|
||||
// URL global, and node's fileURLToPath rejects a foreign instance with
|
||||
// "must be ... an instance of URL. Received an instance of URL".
|
||||
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
|
||||
}
|
||||
|
||||
const panelsSource = () => read('./PluginAssetPanels.vue')
|
||||
const pcDetailSource = () => read('../../../plugins/computers/frontend/views/PCDetail.vue')
|
||||
|
||||
describe('lifting one panel out of its group', () => {
|
||||
it('accepts an only prop', () => {
|
||||
expect(panelsSource()).toMatch(/only:\s*\{\s*type:\s*Array/)
|
||||
})
|
||||
|
||||
it('accepts an exclude prop', () => {
|
||||
expect(panelsSource()).toMatch(/exclude:\s*\{\s*type:\s*Array/)
|
||||
})
|
||||
|
||||
it('lets only bypass the group filter entirely', () => {
|
||||
// Details renders a panel belonging to monitors, so `only` must NOT be
|
||||
// and-ed with the group or it would match nothing.
|
||||
const source = panelsSource()
|
||||
const start = source.indexOf('const groupedPanels')
|
||||
const block = source.slice(start, start + 400)
|
||||
expect(block).toMatch(/if\s*\(props\.only\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the pinned Network Interfaces card', () => {
|
||||
it('renders at the top of Details by panel id', () => {
|
||||
expect(pcDetailSource()).toContain("only=\"['endpoints-interfaces']\"")
|
||||
})
|
||||
|
||||
it('is excluded from System Monitors so it cannot render twice', () => {
|
||||
// Scoped to the monitors instance and matched on membership, not on the
|
||||
// exact array text: more than one panel is lifted now.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('group="monitors"')
|
||||
const block = source.slice(start, start + 200)
|
||||
expect(block).toContain('endpoints-interfaces')
|
||||
})
|
||||
|
||||
it('is still declared in monitors, not moved to a new group', () => {
|
||||
// One declaration, no vocabulary change. If somebody "fixes" this by
|
||||
// adding a reports-style group, the four-file dance applies again.
|
||||
// Bounded by the NEXT panel's id: the columns list contains '},' so
|
||||
// slicing to that would cut the block off before the group key.
|
||||
const plugin = read('../../../plugins/endpoints/plugin.py')
|
||||
const start = plugin.indexOf("'id': 'endpoints-interfaces'")
|
||||
const next = plugin.indexOf("'id': '", start + 10)
|
||||
const block = plugin.slice(start, next === -1 ? undefined : next)
|
||||
expect(block).toContain("'group': 'monitors'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('the header cloud identity block', () => {
|
||||
it('shows the block on template presence, not on this PC having an id', () => {
|
||||
// A site with no Intune/Entra at all must not see a permanent request for
|
||||
// an id it will never have.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('const hasCloudIdentity')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('entraUrlTemplate.value')
|
||||
expect(block).toContain('intuneUrlTemplate.value')
|
||||
})
|
||||
|
||||
it('waits for the portal templates before rendering', () => {
|
||||
// The templates come from an async settings fetch. Rendering the ids
|
||||
// first and adding the buttons a moment later looked like the buttons
|
||||
// were missing until the page was manually refreshed.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('const hasCloudIdentity')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('portalTemplatesLoaded.value')
|
||||
})
|
||||
|
||||
it('marks the templates settled even when the fetch fails', () => {
|
||||
// Otherwise a down settings backend hides the block permanently.
|
||||
// Bounded by the function's own closing brace rather than a character
|
||||
// count: a fixed window silently excluded the finally block as soon as
|
||||
// the retry logic grew, which reported as "the finally is missing" when
|
||||
// the real code was correct. Slice to the next top-level declaration.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('async function loadPortalTemplates')
|
||||
const next = source.indexOf('\nonUnmounted(', start)
|
||||
const block = source.slice(start, next === -1 ? undefined : next)
|
||||
expect(block).toContain('finally')
|
||||
expect(block).toContain('portalTemplatesLoaded.value = true')
|
||||
})
|
||||
|
||||
it('names which id is missing rather than rendering an empty block', () => {
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain('Please provide DeviceID')
|
||||
expect(source).toContain('Please provide ObjectID')
|
||||
})
|
||||
|
||||
it('no longer carries the old Entra Device Info card', () => {
|
||||
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Entra Device Info</h3>')
|
||||
})
|
||||
|
||||
it('no longer carries the hand-written Network card', () => {
|
||||
// Its two halves are the IP History and IP Configuration panels now.
|
||||
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Network</h3>')
|
||||
})
|
||||
|
||||
it('keeps the full ids on screen so they can be copied', () => {
|
||||
// The move to the header was a MOVE, not a redesign. Rendering only an
|
||||
// "Open device" link loses the id itself, which gets pasted into tickets
|
||||
// and Graph queries. Both ids must still be printed in full.
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain('computer.computer.azuredeviceid }}')
|
||||
expect(source).toContain('computer.computer.azureobjectid }}')
|
||||
})
|
||||
|
||||
it('offers a copy button for each id', () => {
|
||||
// Clicking to select still leaves the operator to press ctrl+c; these ids
|
||||
// get pasted into tickets and Graph queries constantly.
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain("copyId(computer.computer.azuredeviceid, 'Device ID')")
|
||||
expect(source).toContain("copyId(computer.computer.azureobjectid, 'Object ID')")
|
||||
})
|
||||
|
||||
it('reports a failed copy instead of a false success', () => {
|
||||
// clipboard.writeText is undefined over plain HTTP and rejects when the
|
||||
// tab is unfocused. Claiming success there would be a lie.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('async function copyId')
|
||||
const block = source.slice(start, start + 420)
|
||||
expect(block).toContain('catch')
|
||||
expect(block).toContain('toast.error')
|
||||
})
|
||||
|
||||
it('keeps the id selectable as a clipboard fallback', () => {
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('.hero-portal-id {')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('user-select: all')
|
||||
})
|
||||
|
||||
it('does not truncate an id with an ellipsis', () => {
|
||||
// A half-copied GUID is worse than no GUID: it looks complete.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('.hero-portal-id {')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).not.toContain('text-overflow: ellipsis')
|
||||
expect(block).toContain('user-select: all')
|
||||
})
|
||||
|
||||
it('keeps the original Intune and Entra buttons', () => {
|
||||
// These carry a race-condition fix. Relabelling them to "Open device" /
|
||||
// "Open object" threw that away once already.
|
||||
const source = pcDetailSource()
|
||||
expect(source).toContain('>Intune</a>')
|
||||
expect(source).toContain('>Entra</a>')
|
||||
})
|
||||
|
||||
it('lays the portal block out horizontally, not as a narrow rail', () => {
|
||||
// A fixed-width right rail wrapped 36-character GUIDs into a tall ragged
|
||||
// stack that grew past the identity block beside it.
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('.hero-portals {')
|
||||
const block = source.slice(start, start + 260)
|
||||
expect(block).toContain('flex-wrap: wrap')
|
||||
expect(block).not.toContain('width: 300px')
|
||||
})
|
||||
|
||||
it('does not restyle the header classes other assets share', () => {
|
||||
// Machines, Printers and friends share .hero-details/.hero-detail-* from
|
||||
// global style.css. A PC-only header must not redefine them.
|
||||
const source = pcDetailSource()
|
||||
const style = source.slice(source.indexOf('<style scoped>'))
|
||||
expect(style).not.toContain('.hero-details {')
|
||||
expect(style).not.toContain('.hero-detail-label {')
|
||||
expect(style).not.toContain('.hero-detail-value {')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the lifted Mapped drives card', () => {
|
||||
it('renders on Details', () => {
|
||||
expect(pcDetailSource()).toContain("['endpoints-mappeddrives']")
|
||||
})
|
||||
|
||||
it('is excluded from System Monitors so it renders once', () => {
|
||||
const source = pcDetailSource()
|
||||
const start = source.indexOf('group="monitors"')
|
||||
const block = source.slice(start, start + 200)
|
||||
expect(block).toContain('endpoints-mappeddrives')
|
||||
})
|
||||
|
||||
it('is still declared in monitors, not moved to a new group', () => {
|
||||
const plugin = read('../../../plugins/endpoints/plugin.py')
|
||||
const start = plugin.indexOf("'id': 'endpoints-mappeddrives'")
|
||||
const block = plugin.slice(start, start + 1200)
|
||||
expect(block).toContain("'group': 'monitors'")
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,15 @@ export function formatValue(value, spec = {}) {
|
||||
const raw = String(value)
|
||||
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
|
||||
}
|
||||
// Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire
|
||||
// format is UTC by contract. Render in the viewer's local zone: an operator
|
||||
// comparing "last reported" against the clock on the wall cannot do the
|
||||
// offset in their head, and the Z suffix invites reading it as local.
|
||||
if (spec.format === 'datetime') {
|
||||
const parsed = new Date(String(value))
|
||||
if (isNaN(parsed)) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
return value
|
||||
}
|
||||
@@ -138,6 +147,14 @@ export function mapMeta(panel, item) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function mapBody(panel, item) {
|
||||
const key = panel.map && panel.map.body
|
||||
if (!key) return null
|
||||
const value = item[key]
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function manageLink(panel, assetid) {
|
||||
return panel.manage ? subst(panel.manage.to, assetid) : null
|
||||
}
|
||||
@@ -210,17 +227,38 @@ export function rollupStatus(panel) {
|
||||
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
|
||||
// time, so a panel with 30 values stays the height of its largest section
|
||||
// instead of dragging one multicol column far past the other.
|
||||
//
|
||||
// A section may instead carry { columns, rows, empty } and render as a table.
|
||||
// This exists so two tables that belong side by side for the reader but share
|
||||
// NO key can sit in one card - Storage is per volume, Disk health is per
|
||||
// physical device. The sections are independent tables by design: nothing here
|
||||
// reconciles rows across them, unlike the mapped-drive merge where two sources
|
||||
// described the same drive.
|
||||
export function tabSections(panel) {
|
||||
const data = panel._data
|
||||
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
|
||||
return sections
|
||||
.map((s) => ({
|
||||
label: s.label,
|
||||
fields: (s.fields || []).map((f) => ({
|
||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||
})),
|
||||
}))
|
||||
.filter((s) => s.fields.length > 0)
|
||||
.map((s) => {
|
||||
if (Array.isArray(s.columns) && s.columns.length) {
|
||||
return {
|
||||
label: s.label,
|
||||
columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })),
|
||||
rows: Array.isArray(s.rows) ? s.rows : [],
|
||||
empty: s.empty || '',
|
||||
fields: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: s.label,
|
||||
fields: (s.fields || []).map((f) => ({
|
||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||
})),
|
||||
}
|
||||
})
|
||||
// A table section declares its columns up front, so it survives having no
|
||||
// rows and shows its own empty message. Dropping it would take the tab with
|
||||
// it and read as "this machine has no disks" rather than "nothing reported".
|
||||
.filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0))
|
||||
}
|
||||
|
||||
// Inline row action for a list panel. A monitored services/processes row may
|
||||
@@ -247,6 +285,16 @@ export function rowAction(panel, item) {
|
||||
}
|
||||
}
|
||||
|
||||
// A table needs the full card width. `table` panels get it via panel.render,
|
||||
// but a `tabs` panel only needs it when one of its sections is a table, and
|
||||
// that is not knowable from the declaration - it depends on fetched data.
|
||||
export function spansFullWidth(panel) {
|
||||
if (!panel) return false
|
||||
if (panel.render === 'table') return true
|
||||
if (panel.render !== 'tabs') return false
|
||||
return tabSections(panel).some((s) => (s.columns || []).length > 0)
|
||||
}
|
||||
|
||||
// A panel declaring `empty` is asking to stay visible with nothing in it and
|
||||
// say so - "No installed software reported." is an answer, where an absent
|
||||
// card is a question. `list` honoured that and `table` did not, for no reason
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||
rowAction, facets, facetedRows,
|
||||
} from './pluginAssetPanels'
|
||||
@@ -64,7 +64,6 @@ describe('list render mapping (warranty)', () => {
|
||||
it('builds the manage link with the assetid', () => {
|
||||
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
|
||||
})
|
||||
|
||||
it('is visible when it has rows or an empty message', () => {
|
||||
expect(panelVisible(warrantyPanel)).toBe(true)
|
||||
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
|
||||
@@ -72,6 +71,24 @@ describe('list render mapping (warranty)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandable row body (activity feed)', () => {
|
||||
const activityPanel = {
|
||||
id: 'endpoints-activity', render: 'list',
|
||||
map: { title: 'title', body: 'body' },
|
||||
}
|
||||
|
||||
it('returns the body only for rows that carry one', () => {
|
||||
expect(mapBody(activityPanel, { title: 'Crash', body: 'Faulting module' }))
|
||||
.toBe('Faulting module')
|
||||
expect(mapBody(activityPanel, { title: 'Crash' })).toBeNull()
|
||||
expect(mapBody(activityPanel, { title: 'Crash', body: '' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the panel declares no body key', () => {
|
||||
expect(mapBody(warrantyPanel, { body: 'ignored' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('list row filtering (tracked services vs processes)', () => {
|
||||
const feedPanel = {
|
||||
id: 'endpoints-tracked-services', render: 'list',
|
||||
|
||||
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
99
frontend/src/components/tabSectionsTable.spec.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tabSections, panelVisible, spansFullWidth } from './pluginAssetPanels'
|
||||
|
||||
// The tabs render type carried label/value sections only. It now also carries
|
||||
// table sections, so that two tables which belong together for the reader but
|
||||
// share no key can sit in one card. This is the shared renderer every plugin
|
||||
// uses, so the label/value path is pinned here alongside the new one.
|
||||
|
||||
function tabsPanel(sections) {
|
||||
return { id: 'p', render: 'tabs', _data: { sections } }
|
||||
}
|
||||
|
||||
const FIELD_SECTION = { label: 'Fields', fields: [{ label: 'A', value: '1' }] }
|
||||
const TABLE_SECTION = {
|
||||
label: 'Volumes',
|
||||
columns: [{ key: 'drive', label: 'Drive' }],
|
||||
rows: [{ drive: 'C:' }],
|
||||
empty: 'No disks reported.',
|
||||
}
|
||||
|
||||
describe('tabSections with table sections', () => {
|
||||
it('keeps rendering label/value sections as before', () => {
|
||||
const sections = tabSections(tabsPanel([FIELD_SECTION]))
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].fields).toEqual([{ label: 'A', value: '1', mono: false }])
|
||||
expect(sections[0].columns).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries columns and rows through for a table section', () => {
|
||||
const sections = tabSections(tabsPanel([TABLE_SECTION]))
|
||||
expect(sections[0].columns).toEqual([{ key: 'drive', label: 'Drive' }])
|
||||
expect(sections[0].rows).toEqual([{ drive: 'C:' }])
|
||||
})
|
||||
|
||||
it('lets one panel mix a table section and a field section', () => {
|
||||
const sections = tabSections(tabsPanel([TABLE_SECTION, FIELD_SECTION]))
|
||||
expect(sections.map((s) => s.label)).toEqual(['Volumes', 'Fields'])
|
||||
})
|
||||
|
||||
// The reason the empty-section filter needed a table branch at all. A card
|
||||
// whose second section is dropped loses that tab, and "no disks" is a very
|
||||
// different claim from "the collector reported nothing".
|
||||
it('keeps an empty table section so its tab and message survive', () => {
|
||||
const sections = tabSections(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))
|
||||
expect(sections).toHaveLength(1)
|
||||
expect(sections[0].empty).toBe('No disks reported.')
|
||||
})
|
||||
|
||||
it('still drops a label/value section with no fields', () => {
|
||||
const sections = tabSections(tabsPanel([{ label: 'Empty', fields: [] }]))
|
||||
expect(sections).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('derives a column label from the key when none is given', () => {
|
||||
const sections = tabSections(tabsPanel([
|
||||
{ label: 'V', columns: [{ key: 'drive' }], rows: [] },
|
||||
]))
|
||||
expect(sections[0].columns[0].label).toBe('Drive')
|
||||
})
|
||||
|
||||
it('treats a missing rows array as no rows rather than throwing', () => {
|
||||
const sections = tabSections(tabsPanel([{ label: 'V', columns: [{ key: 'drive' }] }]))
|
||||
expect(sections[0].rows).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('panelVisible for a tabs panel of tables', () => {
|
||||
it('shows a panel whose only section is an empty table', () => {
|
||||
expect(panelVisible(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))).toBe(true)
|
||||
})
|
||||
|
||||
it('still hides a tabs panel with no sections at all', () => {
|
||||
expect(panelVisible(tabsPanel([]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spansFullWidth', () => {
|
||||
// A six-column table squeezed into one half of a two-column grid is the
|
||||
// failure this prevents.
|
||||
it('spans a tabs panel that contains a table section', () => {
|
||||
expect(spansFullWidth(tabsPanel([TABLE_SECTION]))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not span a tabs panel of label/value sections', () => {
|
||||
expect(spansFullWidth(tabsPanel([FIELD_SECTION]))).toBe(false)
|
||||
})
|
||||
|
||||
it('still spans a plain table panel', () => {
|
||||
expect(spansFullWidth({ render: 'table' })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not span a keyvalue panel', () => {
|
||||
expect(spansFullWidth({ render: 'keyvalue' })).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a null panel', () => {
|
||||
expect(spansFullWidth(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -221,5 +221,11 @@ export default [
|
||||
name: 'users',
|
||||
component: () => import('../../views/settings/UsersList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/shopdbbackups',
|
||||
name: 'shopdbbackups',
|
||||
component: () => import('../../views/settings/ShopdbBackupsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,19 +5,46 @@ import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
let settingsCache = null
|
||||
let inFlight = null
|
||||
let retryAfter = 0
|
||||
|
||||
// Whether the last resolved load actually produced settings. A caller cannot
|
||||
// tell that from the value alone: a failed load and a site that simply has not
|
||||
// configured a key both read as empty, and those want opposite handling -
|
||||
// hide the feature vs try again.
|
||||
export function settingsLoadFailed() {
|
||||
return settingsCache === null
|
||||
}
|
||||
|
||||
// A failed load is cached briefly, not for the page session. A transient blip
|
||||
// used to blank every settings-driven link until the operator reloaded, and
|
||||
// they have no reason to know that would fix it. A short window still bounds a
|
||||
// hard-down backend to two requests a minute per client.
|
||||
const FAILURE_RETRY_MS = 30000
|
||||
|
||||
// The in-flight promise is cached alongside the result. Callers that arrive
|
||||
// during the first load share one request instead of each firing their own;
|
||||
// applyBranding() alone awaits five settings in sequence at bootstrap.
|
||||
async function loadSettings() {
|
||||
if (settingsCache) return settingsCache
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const items = response.data?.data || response.data || []
|
||||
settingsCache = {}
|
||||
for (const s of items) settingsCache[s.key] = s.value
|
||||
} catch (err) {
|
||||
console.error('Error loading site settings:', err)
|
||||
settingsCache = {}
|
||||
}
|
||||
return settingsCache
|
||||
if (inFlight) return inFlight
|
||||
if (retryAfter && Date.now() < retryAfter) return {}
|
||||
inFlight = settingsApi.list()
|
||||
.then((response) => {
|
||||
const items = response.data?.data || response.data || []
|
||||
const loaded = {}
|
||||
for (const s of items) loaded[s.key] = s.value
|
||||
settingsCache = loaded
|
||||
retryAfter = 0
|
||||
return loaded
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error loading site settings:', error)
|
||||
retryAfter = Date.now() + FAILURE_RETRY_MS
|
||||
return {}
|
||||
})
|
||||
.finally(() => { inFlight = null })
|
||||
return inFlight
|
||||
}
|
||||
|
||||
export async function getSetting(key, fallback = '') {
|
||||
@@ -132,10 +159,14 @@ export async function getServicenowUrls() {
|
||||
// setting instead of waiting for a release.
|
||||
//
|
||||
// Empty is the default and means "no link" - the id still renders as text.
|
||||
//
|
||||
// `loaded` distinguishes a real answer from a failed fetch. Both return empty
|
||||
// strings, but only one of them means "this site has no portal links".
|
||||
export async function getEntraDeviceUrls() {
|
||||
const entraUrl = await getSetting('entra_device_url', '')
|
||||
const intuneUrl = await getSetting('intune_device_url', '')
|
||||
return { entraUrl, intuneUrl }
|
||||
const lapsUrl = await getSetting('laps_device_url', '')
|
||||
return { entraUrl, intuneUrl, lapsUrl, loaded: !settingsLoadFailed() }
|
||||
}
|
||||
|
||||
// Printer hostname template. {ip} is replaced with the dash-separated IP.
|
||||
|
||||
119
frontend/src/utils/siteSettings.spec.js
Normal file
119
frontend/src/utils/siteSettings.spec.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// Site settings load once, and a blip does not blank the page for good.
|
||||
//
|
||||
// Two bugs motivated these. The cache used to hold only the RESOLVED object, so
|
||||
// every caller arriving before the first response fired its own request -
|
||||
// applyBranding() alone awaits five settings in sequence at bootstrap. And a
|
||||
// failed load cached {} for the page session, so one bad GET silently blanked
|
||||
// every settings-driven link (the Intune/Entra buttons) until a manual reload.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
const list = vi.fn()
|
||||
|
||||
vi.mock('@/api', () => ({ settingsApi: { list: () => list() } }))
|
||||
|
||||
// The module caches in module scope, so each test needs a fresh copy.
|
||||
async function freshModule() {
|
||||
vi.resetModules()
|
||||
return import('./siteSettings')
|
||||
}
|
||||
|
||||
function respond(items) {
|
||||
return { data: { data: items } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
list.mockReset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('loading', () => {
|
||||
it('fetches once for concurrent callers rather than once per caller', async () => {
|
||||
let release
|
||||
list.mockReturnValue(new Promise((resolve) => { release = resolve }))
|
||||
const settings = await freshModule()
|
||||
|
||||
const waiting = Promise.all([
|
||||
settings.getSetting('entra_device_url'),
|
||||
settings.getSetting('intune_device_url'),
|
||||
settings.getFacilityName(),
|
||||
])
|
||||
release(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
|
||||
const [entra, intune, facility] = await waiting
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(entra).toBe('https://entra/{id}')
|
||||
expect(intune).toBe('')
|
||||
expect(facility).toBe('ShopDB')
|
||||
})
|
||||
|
||||
it('serves later callers from cache without refetching', async () => {
|
||||
list.mockResolvedValue(respond([{ key: 'facility_name', value: 'Wilmington' }]))
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getFacilityName()).toBe('Wilmington')
|
||||
expect(await settings.getFacilityName()).toBe('Wilmington')
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('gives both portal templates to a single caller from one fetch', async () => {
|
||||
list.mockResolvedValue(respond([
|
||||
{ key: 'entra_device_url', value: 'https://entra/{id}' },
|
||||
{ key: 'intune_device_url', value: 'https://intune/{id}' },
|
||||
]))
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getEntraDeviceUrls()).toEqual({
|
||||
entraUrl: 'https://entra/{id}',
|
||||
intuneUrl: 'https://intune/{id}',
|
||||
lapsUrl: '',
|
||||
loaded: true,
|
||||
})
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure', () => {
|
||||
it('falls back without throwing so a caller still renders', async () => {
|
||||
list.mockRejectedValue(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getFacilityName()).toBe('ShopDB')
|
||||
// loaded:false is the point - a failed fetch and an unconfigured site both
|
||||
// read as empty strings, and the caller needs to tell them apart.
|
||||
expect(await settings.getEntraDeviceUrls()).toEqual({
|
||||
entraUrl: '', intuneUrl: '', lapsUrl: '', loaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hammer a backend that is down', async () => {
|
||||
list.mockRejectedValue(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
await settings.getFacilityName()
|
||||
await settings.getFacilityName()
|
||||
await settings.getFacilityName()
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers after the retry window instead of staying blank for the session', async () => {
|
||||
list.mockRejectedValueOnce(new Error('network down'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const settings = await freshModule()
|
||||
|
||||
expect(await settings.getSetting('entra_device_url')).toBe('')
|
||||
|
||||
vi.setSystemTime(Date.now() + 31000)
|
||||
list.mockResolvedValue(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
|
||||
|
||||
expect(await settings.getSetting('entra_device_url')).toBe('https://entra/{id}')
|
||||
expect(list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -40,9 +40,9 @@
|
||||
<div v-if="showDisplays" class="nav-section">Displays</div>
|
||||
<a v-if="showShopfloor" :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
||||
<a v-if="showPartsKiosk" :href="withBase('/parts-kiosk')"
|
||||
target="_blank" class="external-link">Parts Kiosk</a>
|
||||
<a v-if="hasRoute('/usb-kiosk')" :href="withBase('/usb-kiosk')"
|
||||
<a v-if="showUsbKiosk" :href="withBase('/usb-kiosk')"
|
||||
target="_blank" class="external-link">USB Kiosk</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
@@ -127,6 +127,7 @@ import { dashboardApi, notificationsApi } from '../api'
|
||||
import { fiscalWeek } from '@/utils/fiscalWeek'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { isPluginEnabled } from '../composables/enabledPlugins'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -138,6 +139,12 @@ const authStore = useAuthStore()
|
||||
// dead-ends on a blank page. Route existence is the true "is it in this build"
|
||||
// test - more reliable than plugin-enabled state, which a copied registry can
|
||||
// report for a plugin that was never staged.
|
||||
//
|
||||
// The kiosks need BOTH tests. Staged-but-disabled is a real state: a full build
|
||||
// stages every route, so a site that turns the plugin OFF still had the link,
|
||||
// pointing at a kiosk its backend refuses. Enabled-but-unstaged is the state the
|
||||
// paragraph above is about, and isPluginEnabled fails OPEN when it cannot reach
|
||||
// the plugin list, so it cannot carry the test on its own.
|
||||
const stagedRoutePaths = new Set(router.getRoutes().map(r => r.path))
|
||||
const hasRoute = (path) => stagedRoutePaths.has(path)
|
||||
// Shopfloor Dashboard is a core view but runs entirely on the notifications
|
||||
@@ -145,8 +152,10 @@ const hasRoute = (path) => stagedRoutePaths.has(path)
|
||||
// is empty, so gate the link on the notifications plugin being in this build.
|
||||
const showShopfloor = hasRoute('/shopfloor')
|
||||
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
||||
|| hasRoute('/usb-kiosk')
|
||||
const showPartsKiosk = hasRoute('/parts-kiosk') && isPluginEnabled('printedparts')
|
||||
const showUsbKiosk = hasRoute('/usb-kiosk') && isPluginEnabled('usb')
|
||||
const showDisplays = showShopfloor || hasRoute('/tv') || showPartsKiosk
|
||||
|| showUsbKiosk
|
||||
const routeViewKey = computed(() =>
|
||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||
)
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
<div class="form-group">
|
||||
<label>Site logo <span class="hint">(optional)</span></label>
|
||||
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
||||
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
|
||||
<input v-model="logoFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="siteLogo" :src="withBase(siteLogo)" class="wizard-map-thumb" alt="site logo" />
|
||||
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,15 +93,17 @@
|
||||
<!-- Floor map -->
|
||||
<div v-else-if="current.key === 'map'">
|
||||
<h2>Floor map</h2>
|
||||
<p class="hint">Upload your facility blueprint (light and dark), and set its native pixel size.</p>
|
||||
<p class="hint">Upload your facility blueprint (light and dark) for the default level, and set its native pixel size. Additional levels can be added later in Settings.</p>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (light theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||
<input v-model="blueprintLightFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (dark theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||
<input v-model="blueprintDarkFilename" type="text" class="form-control" placeholder="No file selected" readonly />
|
||||
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -203,14 +206,24 @@ const blueprintLight = ref('')
|
||||
const blueprintDark = ref('')
|
||||
const logoUploading = ref(false)
|
||||
const siteLogo = ref('')
|
||||
// A file input shows the chosen name in the browser chrome, which the kiosk-
|
||||
// style full-screen wizard hides, so the operator had no confirmation that the
|
||||
// file they picked was the one being uploaded.
|
||||
const logoFilename = ref('')
|
||||
const blueprintLightFilename = ref('')
|
||||
const blueprintDarkFilename = ref('')
|
||||
|
||||
async function uploadSiteLogo(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
logoFilename.value = file.name
|
||||
logoUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
||||
siteLogo.value = data?.data?.value || ''
|
||||
// Same path every time, so without a cache-buster the preview shows the
|
||||
// PREVIOUS logo and the upload reads as having silently failed. Display
|
||||
// only - nothing persists this value.
|
||||
siteLogo.value = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
|
||||
toast.success('Logo uploaded')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Upload failed'))
|
||||
@@ -223,10 +236,12 @@ async function uploadSiteLogo(event) {
|
||||
async function uploadBlueprint(theme, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
if (theme === 'light') blueprintLightFilename.value = file.name
|
||||
else blueprintDarkFilename.value = file.name
|
||||
mapUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
||||
const url = data?.data?.value
|
||||
const url = data?.data?.value ? `${data.data.value}?t=${Date.now()}` : ''
|
||||
if (theme === 'light') blueprintLight.value = url
|
||||
else blueprintDark.value = url
|
||||
toast.success('Blueprint uploaded')
|
||||
@@ -307,6 +322,17 @@ onMounted(async () => {
|
||||
} catch (err) { /* not set yet */ }
|
||||
}
|
||||
}
|
||||
// Show whatever logo the site is already on, so re-running the wizard does
|
||||
// not present an empty slot as if branding had never been set. Stored as a
|
||||
// root-absolute path; the template applies withBase, so do NOT prefix here or
|
||||
// a subpath mount gets it twice (/shopdb/shopdb/...).
|
||||
const SHIPPED_LOGO = '/ge-aerospace-logo.svg'
|
||||
try {
|
||||
const response = await settingsApi.get('site_logo')
|
||||
siteLogo.value = response.data?.data?.value || SHIPPED_LOGO
|
||||
} catch (err) {
|
||||
siteLogo.value = SHIPPED_LOGO
|
||||
}
|
||||
})
|
||||
|
||||
async function saveStep() {
|
||||
|
||||
891
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
891
frontend/src/views/settings/ShopdbBackupsList.vue
Normal file
@@ -0,0 +1,891 @@
|
||||
<template>
|
||||
<div class="backups-page">
|
||||
<div class="page-header">
|
||||
<h2>Backups</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="!isConfigured" class="not-configured">
|
||||
<strong>Site Backups is not configured on this deployment.</strong>
|
||||
<span>
|
||||
The feature needs the backup overlay (a separate container writing sets
|
||||
onto a shared volume) and a readable backups root. A Windows/IIS install
|
||||
does not run it, so nothing here would have a store behind it.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Confidence banner: the single most reassuring thing an admin wants to
|
||||
see - is there a recent, verified backup? Everything else is secondary. -->
|
||||
<div v-if="isConfigured" class="confidence-banner" :class="bannerClass">
|
||||
<span class="banner-icon">
|
||||
<component :is="bannerIcon" :size="22" />
|
||||
</span>
|
||||
<div class="banner-text">
|
||||
<strong>{{ bannerHeadline }}</strong>
|
||||
<span class="banner-sub">{{ bannerSub }}</span>
|
||||
</div>
|
||||
<button class="btn-primary" :disabled="isBusy" @click="runBackup">
|
||||
{{ isBusy ? 'Working...' : 'Back up now' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Light explanation, details tucked away. Leads the UI-simplification
|
||||
charge: one plain sentence up top, the depth behind a disclosure. -->
|
||||
<details class="explain">
|
||||
<summary>What is backed up, and how restore works</summary>
|
||||
<div class="explain-body">
|
||||
<p>
|
||||
Each backup is one recoverable set: the full database (users,
|
||||
settings, and every asset), the uploaded files, and a manifest that
|
||||
records the exact code version so a restore can never land on the
|
||||
wrong schema.
|
||||
</p>
|
||||
<p>
|
||||
Backups keep <strong>any day in the last 5</strong>, plus weekly and
|
||||
monthly copies. Restoring your <em>data</em> is separate from rolling
|
||||
back <em>code</em>: code is handled in git. If a restore needs a newer
|
||||
code version than you are running, this screen tells you before it
|
||||
lets you proceed.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Destination configuration: where sets ship and the credentials to get
|
||||
there. SMB and AWS are wired but not yet armed (banner says so)
|
||||
because their client libraries wait on the security review. -->
|
||||
<details class="explain destination" :open="settingsOpen">
|
||||
<summary @click.prevent="settingsOpen = !settingsOpen">
|
||||
Backup destination
|
||||
<span class="dest-chip">{{ destinationLabel(settings.destinationkind) }}</span>
|
||||
</summary>
|
||||
<div class="explain-body">
|
||||
<div v-if="settingsLoading" class="muted">Loading destination...</div>
|
||||
<template v-else>
|
||||
<label class="field">
|
||||
<span class="field-label">Ship backups to</span>
|
||||
<select v-model="settings.destinationkind" class="input">
|
||||
<option
|
||||
v-for="kind in settings.destinationkinds"
|
||||
:key="kind"
|
||||
:value="kind"
|
||||
>
|
||||
{{ destinationLabel(kind) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p v-if="settings.destinationkind === 'local'" class="muted small">
|
||||
Sets stay on the mounted backups volume. No credentials needed.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="not-armed"
|
||||
>
|
||||
<component :is="ShieldAlert" :size="16" />
|
||||
<span>
|
||||
This destination is configured but not yet armed. Its client
|
||||
library is held back until the security review clears it against
|
||||
external exploit; until then sets keep writing locally.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.destinationkind === 'smb'">
|
||||
<label class="field">
|
||||
<span class="field-label">Server host</span>
|
||||
<input v-model="settings.fields.backup_smb_host" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Share</span>
|
||||
<input v-model="settings.fields.backup_smb_share" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Path within share</span>
|
||||
<input v-model="settings.fields.backup_smb_path" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Username</span>
|
||||
<input v-model="settings.fields.backup_smb_username" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Password</span>
|
||||
<input
|
||||
v-model="settings.fields.backup_smb_password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else-if="settings.destinationkind === 'aws'">
|
||||
<label class="field">
|
||||
<span class="field-label">Region</span>
|
||||
<input v-model="settings.fields.backup_aws_region" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Bucket</span>
|
||||
<input v-model="settings.fields.backup_aws_bucket" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Key prefix</span>
|
||||
<input v-model="settings.fields.backup_aws_prefix" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Access key id</span>
|
||||
<input v-model="settings.fields.backup_aws_accesskeyid" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Secret access key</span>
|
||||
<input
|
||||
v-model="settings.fields.backup_aws_secretaccesskey"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" :disabled="savingSettings" @click="saveSettings">
|
||||
{{ savingSettings ? 'Saving...' : 'Save destination' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="loading" class="muted">Loading backups...</div>
|
||||
<div v-else-if="!sets.length" class="empty-state">
|
||||
<p>No backups yet. Run your first one above - it is safe to do anytime.</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="backups-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Taken</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
<th>Restore</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="set in sets" :key="set.snapshotid">
|
||||
<td>
|
||||
<span class="taken">{{ formatTaken(set.takenat) }}</span>
|
||||
<span class="setid">{{ set.snapshotid }}</span>
|
||||
</td>
|
||||
<td>{{ formatSize(set.sizebytes) }}</td>
|
||||
<td>
|
||||
<span class="chip" :class="statusClass(set)">
|
||||
{{ statusLabel(set) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn-restore"
|
||||
:class="{ blocked: !set.iscompatible }"
|
||||
:disabled="isBusy"
|
||||
@click="openRestore(set)"
|
||||
>
|
||||
Restore...
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Operational run records, written by the backup container onto the
|
||||
shared volume. Two cards: what was backed up, and what was restored. -->
|
||||
<div class="history-cards">
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">Backup history</h3>
|
||||
<p v-if="!backups.length" class="muted">No backup runs recorded yet.</p>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="(entry, index) in backups" :key="'b' + index" class="history-row">
|
||||
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
|
||||
{{ outcomeLabel(entry.outcome) }}
|
||||
</span>
|
||||
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
|
||||
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="history-card">
|
||||
<h3 class="history-title">Restore history</h3>
|
||||
<p v-if="!restores.length" class="muted">No restore runs recorded yet.</p>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="(entry, index) in restores" :key="'r' + index" class="history-row">
|
||||
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
|
||||
{{ outcomeLabel(entry.outcome) }}
|
||||
</span>
|
||||
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
|
||||
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
|
||||
UI budget on the compatibility verdict before allowing the click. -->
|
||||
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
|
||||
<div class="modal">
|
||||
<h3>Restore this backup?</h3>
|
||||
<p class="modal-set">{{ formatTaken(restoreTarget.takenat || restoreTarget.snapshotid) }}</p>
|
||||
|
||||
<!-- Progress mode: once a restore is queued we swap the compatibility
|
||||
body for an indeterminate spinner and the current phase label. -->
|
||||
<template v-if="restorePhase">
|
||||
<div class="restore-progress" :class="{ failed: restorePhase === 'failed' }">
|
||||
<span v-if="isRestoreRunning" class="spinner" aria-hidden="true"></span>
|
||||
<component
|
||||
:is="restorePhase === 'done' ? ShieldCheck : AlertTriangle"
|
||||
v-else
|
||||
:size="18"
|
||||
/>
|
||||
<span class="restore-phase">{{ restorePhaseLabel }}</span>
|
||||
</div>
|
||||
<p class="restore-hint">
|
||||
Keep this open until the restore finishes. This can take a moment.
|
||||
</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
:disabled="isRestoreRunning"
|
||||
@click="closeRestore"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
|
||||
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
|
||||
<span v-if="restoreTarget.iscompatible">
|
||||
Compatible with the code you are running. Safe to restore.
|
||||
</span>
|
||||
<span v-else>
|
||||
This backup was taken on a newer schema than the running code.
|
||||
Upgrade the code first, then retry - restore is blocked until then.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="explain small">
|
||||
<summary>What restoring does</summary>
|
||||
<div class="explain-body">
|
||||
<p>
|
||||
Restoring replaces the current database and uploaded files with the
|
||||
contents of this backup. Code is not changed. This cannot be undone,
|
||||
so it is gated on the compatibility check above.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-ghost" @click="closeRestore">Cancel</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
:disabled="!restoreTarget.iscompatible || isBusy"
|
||||
@click="confirmRestore"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
|
||||
import api from '../../api'
|
||||
import { useToast } from '../../composables/toast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const sets = ref([])
|
||||
const loading = ref(true)
|
||||
const isBusy = ref(false)
|
||||
const lastGoodAt = ref(null)
|
||||
// Assume configured until the list says otherwise, so a slow first load
|
||||
// does not flash the not-configured notice on a working install.
|
||||
const isConfigured = ref(true)
|
||||
const restoreTarget = ref(null)
|
||||
|
||||
// Live restore progress. restoreStatus mirrors the api /status.restore payload;
|
||||
// restorePhase drives the indeterminate spinner and label in the modal.
|
||||
const restoreStatus = ref(null)
|
||||
let statusTimer = null
|
||||
let restoreStartedAt = 0
|
||||
// The Flask-less backup sidecar loads the dump and parks the restore in
|
||||
// 'upgrading', because it cannot run `flask db upgrade` itself. The api can, so
|
||||
// the browser pokes the finalize endpoint once to roll the restored data up to
|
||||
// the running code. Guarded so we fire it exactly once per restore run.
|
||||
let finalizeRequested = false
|
||||
|
||||
const backups = ref([])
|
||||
const restores = ref([])
|
||||
|
||||
// Human labels for the phase names the watcher scripts write. Anything we do
|
||||
// not recognize falls back to the raw phase so a new phase still shows.
|
||||
const RESTORE_PHASE_LABELS = {
|
||||
queued: 'Queued...',
|
||||
checking: 'Checking compatibility...',
|
||||
files: 'Restoring files...',
|
||||
database: 'Loading database...',
|
||||
upgrading: 'Upgrading schema to code...',
|
||||
done: 'Restore complete',
|
||||
failed: 'Restore failed',
|
||||
}
|
||||
|
||||
const restorePhase = computed(() => restoreStatus.value?.phase || null)
|
||||
const isRestoreRunning = computed(() => {
|
||||
const phase = restorePhase.value
|
||||
return !!phase && phase !== 'done' && phase !== 'failed'
|
||||
})
|
||||
const restorePhaseLabel = computed(() => {
|
||||
const phase = restorePhase.value
|
||||
if (!phase) return ''
|
||||
return RESTORE_PHASE_LABELS[phase] || phase
|
||||
})
|
||||
|
||||
const settingsOpen = ref(false)
|
||||
const settingsLoading = ref(true)
|
||||
const savingSettings = ref(false)
|
||||
const settings = ref({
|
||||
destinationkind: 'local',
|
||||
destinationkinds: ['local'],
|
||||
fields: {},
|
||||
})
|
||||
|
||||
const DESTINATION_LABELS = {
|
||||
local: 'Local volume',
|
||||
smb: 'SMB / CIFS share',
|
||||
aws: 'AWS S3 bucket',
|
||||
}
|
||||
|
||||
function destinationLabel(kind) {
|
||||
return DESTINATION_LABELS[kind] || kind
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
settingsLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/settings')
|
||||
const data = response.data.data || {}
|
||||
settings.value = {
|
||||
destinationkind: data.destinationkind || 'local',
|
||||
destinationkinds: data.destinationkinds || ['local'],
|
||||
fields: data.fields || {},
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Could not load backup destination.')
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
savingSettings.value = true
|
||||
try {
|
||||
const response = await api.put('/shopdbbackups/settings', {
|
||||
destinationkind: settings.value.destinationkind,
|
||||
fields: settings.value.fields,
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
settings.value = {
|
||||
destinationkind: data.destinationkind || 'local',
|
||||
destinationkinds: data.destinationkinds || ['local'],
|
||||
fields: data.fields || {},
|
||||
}
|
||||
toast.success('Backup destination saved.')
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.data?.error?.message
|
||||
toast.error(message || 'Could not save the backup destination.')
|
||||
} finally {
|
||||
savingSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups')
|
||||
sets.value = response.data.data || []
|
||||
lastGoodAt.value = response.data.meta?.lastgoodat || null
|
||||
// A deployment without the backup overlay has no store behind any of
|
||||
// this. Say so instead of showing 'No verified backup yet', which reads
|
||||
// as a backup that failed rather than a feature that is not installed.
|
||||
isConfigured.value = response.data.meta?.configured !== false
|
||||
} catch (error) {
|
||||
toast.error('Could not load backups.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackup() {
|
||||
isBusy.value = true
|
||||
try {
|
||||
const response = await api.post('/shopdbbackups')
|
||||
toast.success(response.data.message || 'Backup queued.')
|
||||
} catch (error) {
|
||||
toast.error('Could not queue a backup.')
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
function openRestore(set) {
|
||||
restoreTarget.value = set
|
||||
}
|
||||
|
||||
function closeRestore() {
|
||||
stopStatusPolling()
|
||||
restoreStatus.value = null
|
||||
restoreTarget.value = null
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
if (!restoreTarget.value?.iscompatible) return
|
||||
isBusy.value = true
|
||||
try {
|
||||
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
|
||||
toast.success('Restore queued and validated against the live schema.')
|
||||
// Keep the modal open and switch it to progress mode; polling drives the
|
||||
// spinner through each phase until the watcher reports done or failed.
|
||||
startStatusPolling()
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.data?.error?.message
|
||||
toast.error(message || 'Could not queue the restore.')
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/history')
|
||||
const data = response.data.data || {}
|
||||
backups.value = data.backups || []
|
||||
restores.value = data.restores || []
|
||||
} catch (error) {
|
||||
// History is non-critical; a failure here should not disrupt the page.
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
let status
|
||||
try {
|
||||
const response = await api.get('/shopdbbackups/status')
|
||||
status = response.data.data?.restore || null
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
// Ignore a terminal status left over from a previous restore: if it finished
|
||||
// before we started this one, it is stale and would close the modal instantly
|
||||
// before the spinner ever paints. Wait for a fresh status from this run.
|
||||
if (status && (status.phase === 'done' || status.phase === 'failed')) {
|
||||
const finishedAt = new Date(normalizeStamp(status.updatedat || '')).getTime()
|
||||
if (Number.isNaN(finishedAt) || finishedAt < restoreStartedAt) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreStatus.value = status
|
||||
const phase = status?.phase
|
||||
if (phase === 'upgrading' && !finalizeRequested) {
|
||||
// Hand off to the api container, which owns the Flask CLI, to run the
|
||||
// post-load schema upgrade. Fire once; polling keeps tracking until it
|
||||
// reports done or failed.
|
||||
finalizeRequested = true
|
||||
try {
|
||||
await api.post('/shopdbbackups/finalize-restore')
|
||||
} catch (error) {
|
||||
// The finalize endpoint stamps its own failed status on error, which the
|
||||
// next poll surfaces; nothing extra to do here.
|
||||
}
|
||||
}
|
||||
if (phase === 'done' || phase === 'failed') {
|
||||
stopStatusPolling()
|
||||
if (phase === 'done') {
|
||||
toast.success('Restore complete.')
|
||||
closeRestore()
|
||||
} else {
|
||||
toast.error(status?.message || 'Restore failed.')
|
||||
}
|
||||
load()
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling()
|
||||
restoreStartedAt = Date.now()
|
||||
finalizeRequested = false
|
||||
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
|
||||
// Poll on an interval only; skip the immediate call so the seeded "queued"
|
||||
// phase is guaranteed to paint before the first real status can replace it.
|
||||
statusTimer = setInterval(pollStatus, 1000)
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer)
|
||||
statusTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// -- Confidence banner state ------------------------------------------------
|
||||
const STALE_HOURS = 48
|
||||
|
||||
const hoursSinceLastGood = computed(() => {
|
||||
if (!lastGoodAt.value) return null
|
||||
const then = new Date(normalizeStamp(lastGoodAt.value)).getTime()
|
||||
return (Date.now() - then) / 36e5
|
||||
})
|
||||
|
||||
const bannerState = computed(() => {
|
||||
if (!lastGoodAt.value) return 'none'
|
||||
if (hoursSinceLastGood.value > STALE_HOURS) return 'stale'
|
||||
return 'good'
|
||||
})
|
||||
|
||||
const bannerClass = computed(() => `banner-${bannerState.value}`)
|
||||
const bannerIcon = computed(() => ({
|
||||
good: ShieldCheck, stale: Clock, none: ShieldAlert,
|
||||
}[bannerState.value]))
|
||||
const bannerHeadline = computed(() => ({
|
||||
good: 'Your data is protected',
|
||||
stale: 'Backup is overdue',
|
||||
none: 'No verified backup yet',
|
||||
}[bannerState.value]))
|
||||
const bannerSub = computed(() => {
|
||||
if (bannerState.value === 'none') return 'Run one now - it only takes a moment.'
|
||||
return `Last verified backup ${formatTaken(lastGoodAt.value)}.`
|
||||
})
|
||||
|
||||
// -- Formatting -------------------------------------------------------------
|
||||
function formatTaken(value) {
|
||||
if (!value) return 'unknown'
|
||||
const date = new Date(normalizeStamp(value))
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
// Stamps are UTC; render them in UTC so the displayed time matches the
|
||||
// Zulu-stamped set id rather than shifting to the operator's local zone.
|
||||
return date.toLocaleString(undefined, { timeZone: 'UTC', timeZoneName: 'short' })
|
||||
}
|
||||
|
||||
function outcomeClass(outcome) {
|
||||
return outcome === 'success' ? 'outcome-success' : 'outcome-failed'
|
||||
}
|
||||
|
||||
function outcomeLabel(outcome) {
|
||||
return outcome === 'success' ? 'Success' : 'Failed'
|
||||
}
|
||||
|
||||
// Backup ids and manifest stamps use a filename-safe UTC form with no colons
|
||||
// (YYYY-MM-DDTHHMMSSZ, sometimes prefixed with a site name), which the Date
|
||||
// parser rejects. Reinsert the colons so it parses as real ISO 8601.
|
||||
function normalizeStamp(value) {
|
||||
const match = String(value).match(/(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/)
|
||||
if (!match) return value
|
||||
const [, day, hour, minute, second] = match
|
||||
return `${day}T${hour}:${minute}:${second}Z`
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function statusClass(set) {
|
||||
if (set.ischeckpassed) return 'chip-verified'
|
||||
if (!set.hasmanifest) return 'chip-unverified'
|
||||
return 'chip-pending'
|
||||
}
|
||||
|
||||
function statusLabel(set) {
|
||||
if (set.ischeckpassed) return 'Verified'
|
||||
if (!set.hasmanifest) return 'Unverified'
|
||||
return 'Not checked'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadSettings()
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStatusPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confidence-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.confidence-banner .banner-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.banner-sub {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.banner-good { border-color: #2f855a; }
|
||||
.banner-good .banner-icon { color: #38a169; }
|
||||
.banner-stale { border-color: #b7791f; }
|
||||
.banner-stale .banner-icon { color: #d69e2e; }
|
||||
.banner-none { border-color: #c53030; }
|
||||
.banner-none .banner-icon { color: #e53e3e; }
|
||||
|
||||
.not-configured {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color, #d8dbe0);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted, #f6f7f9);
|
||||
}
|
||||
|
||||
.explain {
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.explain.small { margin: 0.75rem 0; }
|
||||
.explain summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-light);
|
||||
user-select: none;
|
||||
}
|
||||
.explain-body {
|
||||
padding: 0 0.9rem 0.8rem 0.9rem;
|
||||
}
|
||||
.explain-body p {
|
||||
margin: 0 0 0.6rem 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.backups-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.backups-table th,
|
||||
.backups-table td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.backups-table th {
|
||||
color: var(--text-light);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.taken { display: block; color: var(--text); }
|
||||
.setid { display: block; color: var(--text-light); font-size: 0.74rem; }
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-verified { background: rgba(56, 161, 105, 0.18); color: #48bb78; }
|
||||
.chip-pending { background: rgba(214, 158, 46, 0.18); color: #ecc94b; }
|
||||
.chip-unverified { background: rgba(229, 62, 62, 0.18); color: #fc8181; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
.btn-restore {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-restore.blocked { opacity: 0.55; }
|
||||
|
||||
.empty-state, .muted {
|
||||
color: var(--text-light);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
width: min(460px, 92vw);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
.modal h3 { margin: 0 0 0.3rem 0; }
|
||||
.modal-set { color: var(--text-light); font-size: 0.8rem; margin: 0 0 0.9rem 0; }
|
||||
.compat {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
|
||||
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
|
||||
|
||||
.restore-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(56, 161, 105, 0.15);
|
||||
color: #9ae6b4;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.restore-progress.failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
|
||||
.restore-phase { font-weight: 600; }
|
||||
.restore-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(154, 230, 180, 0.35);
|
||||
border-top-color: #9ae6b4;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.history-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.history-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.history-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.history-row:first-child { border-top: none; }
|
||||
.history-outcome {
|
||||
flex-shrink: 0;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.outcome-success { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
|
||||
.outcome-failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
|
||||
.history-set {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.history-when { color: var(--text-light); flex-shrink: 0; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #c53030;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// plugins, access) cluster together at the end.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
@@ -113,6 +113,7 @@ export const settingsGroups = [
|
||||
title: 'System',
|
||||
cards: [
|
||||
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
|
||||
{ to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -174,7 +174,9 @@ async function loadData() {
|
||||
}
|
||||
|
||||
if (types.status === 'fulfilled') {
|
||||
pctypes.value = types.value.data.data || []
|
||||
// Plain array, same as applications below. Reading .data.data threw
|
||||
// inside this try, so apps, actions and monitors never got assigned.
|
||||
pctypes.value = types.value || []
|
||||
} else {
|
||||
failures.push('PC types')
|
||||
console.error('Error loading PC types:', types.reason)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -573,12 +573,14 @@ onMounted(async () => {
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
businessunitsApi.listAll(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
// listAll() pages past the server's 100-row cap and returns the ROWS, not
|
||||
// an axios response, so these are read directly. Reading .data.data here
|
||||
// throws and takes the whole form down with it.
|
||||
vendors.value = vendorRes || []
|
||||
models.value = allModels
|
||||
locations.value = locRes || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
@@ -636,7 +638,11 @@ onMounted(async () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
// A client-side TypeError has no err.response, so apiError() would report
|
||||
// only the fallback and hide which field broke. Name it either way.
|
||||
error.value = err?.response
|
||||
? apiError(err, 'Failed to load data')
|
||||
: `Failed to load data: ${err?.message || err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -107,8 +107,10 @@ onMounted(() => loadData())
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
// listAll resolves to a plain array (fetchAllPages), not an axios
|
||||
// response. Reading .data.data threw and left the page empty.
|
||||
const response = await computersApi.types.listAll({ active: false })
|
||||
pcTypes.value = response.data.data || []
|
||||
pcTypes.value = response || []
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
} finally {
|
||||
|
||||
@@ -10,8 +10,8 @@ schema changes, update this file in the same change.
|
||||
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
||||
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 6). No
|
||||
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 7). No
|
||||
querystring fallback.
|
||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||
report per endpoint.
|
||||
@@ -51,9 +51,69 @@ arrays. `adopt` is first-contact only.
|
||||
}
|
||||
],
|
||||
"scheduledtasks": [ /* opaque array */ ],
|
||||
"disks": [
|
||||
{
|
||||
"drive": "C:",
|
||||
"label": "OS",
|
||||
"filesystem": "NTFS",
|
||||
"sizebytes": 511000000000,
|
||||
"freebytes": 128000000000,
|
||||
"usedbytes": 383000000000,
|
||||
"freepercent": 25.0 // precomputed; a threshold is a direct compare
|
||||
}
|
||||
],
|
||||
"bios": { /* opaque object: vendor, version, release date */ },
|
||||
"memory": { /* opaque object: totalbytes, freebytes */ },
|
||||
"processor": { /* opaque object: name, cores, logicalprocessors, maxclockmhz */ },
|
||||
"osinfo": { /* opaque object: build, version, install date */ },
|
||||
"domain": "shopfloor.example.com", // domain or workgroup membership
|
||||
"pendingreboot": false, // omit when unknown; absent != false
|
||||
"rebootreasons": [], // [] = checked, nothing pending; see notes
|
||||
"bootinfo": {
|
||||
"hiberbootenabled": false, // fast startup CONFIGURED
|
||||
"lastboottype": "Cold" // what the last boot DID: Cold|Fast startup|Resume|Unknown
|
||||
},
|
||||
"defender": { // persisted, deliberately not surfaced
|
||||
"serviceenabled": true,
|
||||
"realtimeenabled": true,
|
||||
"signatureagedays": 0, // real 0 = updated today; null = did not report
|
||||
"antispywareagedays": 0,
|
||||
"quickscanagedays": 0
|
||||
},
|
||||
"diskhealth": [ // per PHYSICAL device; see notes
|
||||
{
|
||||
"deviceid": "0",
|
||||
"model": "Samsung SSD 990 PRO 1TB",
|
||||
"mediatype": "SSD",
|
||||
"bustype": "NVMe",
|
||||
"healthstatus": "Healthy",
|
||||
"predictfailure": false, // precomputed; null = drive did not answer
|
||||
"source": "physicaldisk" // or "diskdrive"
|
||||
}
|
||||
],
|
||||
"usercontext": { // omitted entirely when nobody is signed in
|
||||
"netuse": [
|
||||
{
|
||||
"localname": "Z:",
|
||||
"remotename": "\\\\fileserver\\programs",
|
||||
"status": "OK",
|
||||
"username": "SHOPFLOOR\\operator",
|
||||
"persistent": true
|
||||
}
|
||||
],
|
||||
"userlisteners": [
|
||||
{ "localaddress": "0.0.0.0", "localport": 7878, "processid": 4512, "processname": "DncMain" }
|
||||
],
|
||||
"idleseconds": 45,
|
||||
"username": "operator",
|
||||
"elevated": false, // token elevation of the collecting process;
|
||||
// a spool stamped true is REJECTED (see 5.3)
|
||||
"collectedat": "2025-01-14T06:12:00Z"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
|
||||
"eventrecordid": 184412, // Windows record id; dedupe key, see notes
|
||||
"eventchannel": "System",
|
||||
"eventsource": "EventLog",
|
||||
"eventtime": "2025-01-14T06:11:58Z",
|
||||
@@ -81,6 +141,71 @@ Notes:
|
||||
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
|
||||
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
|
||||
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
|
||||
- `eventrecordid` is the Windows event record id: monotonic and unique within a
|
||||
channel on a machine. The agent uses it as a per-channel harvest watermark and
|
||||
the server enforces a unique index on
|
||||
`(assetid, eventchannel, eventrecordid)`, so one real event is stored exactly
|
||||
once. It is nullable: an older agent omits it, and MySQL permits repeated
|
||||
NULLs in a unique index, so those reports keep the previous append behavior
|
||||
rather than failing ingest. Send it whenever the source record exposes one.
|
||||
- `disks`/`bios`/`memory`/`processor`/`osinfo`/`usercontext` are stored verbatim
|
||||
as JSON snapshots on the endpoint state, newest-wins. `domain` and
|
||||
`pendingreboot` are scalar columns because the fleet view filters and alerts
|
||||
on them.
|
||||
- OMITTING a key is not the same as sending an empty value. Every one of these
|
||||
fields is applied only when the key is PRESENT, so a report that omits it
|
||||
preserves the last-known value. Send `null`/`[]` only when you mean "this is
|
||||
genuinely empty now"; omit when you simply did not collect it this cycle.
|
||||
- `pendingreboot` follows that rule strictly: omit it when the check failed. A
|
||||
literal `false` asserts the endpoint is confirmed NOT holding a reboot.
|
||||
- `rebootreasons` is NOT redundant with `pendingreboot`. That key is a single
|
||||
bool OR-ed from three registry signals and cannot say which fired, and the
|
||||
difference is operational: `pendingfilerename` means files are staged for
|
||||
replacement on next boot, so the box is mid-servicing, while a lone
|
||||
`componentbasedservicing` or `windowsupdate` key can sit set for weeks on a
|
||||
machine nobody has rebooted. Same boolean, very different urgency. An EMPTY
|
||||
array means checked and nothing pending; OMIT the key when the check failed.
|
||||
- `bootinfo` carries two distinct facts that must not be collapsed:
|
||||
`hiberbootenabled` is whether fast startup is CONFIGURED (registry, always
|
||||
readable) and `lastboottype` is what the last boot ACTUALLY did (Kernel-Boot
|
||||
event 27). The registry cannot tell you what the last boot did, and the event
|
||||
may have aged out of the log, so neither substitutes for the other. This
|
||||
exists because uptime LIES under fast startup: a box reports days of uptime
|
||||
after what the operator experienced as a shutdown.
|
||||
- `defender` is stored but deliberately NOT surfaced. Defender produces known
|
||||
false positives in this environment, so wiring it to a card now would train
|
||||
operators to ignore that card. Signature/scan ages are in DAYS; a real `0`
|
||||
means updated today, so send `null` when Defender did not report rather than
|
||||
defaulting to zero.
|
||||
- `interfaces` entries gained `linkspeedbps` and `fullduplex`, paired on by MAC
|
||||
from `MSFT_NetAdapter`. Both are `null` when link state was unreadable or the
|
||||
adapter is disconnected - an unknown duplex must not read as half-duplex.
|
||||
- `diskhealth` is per PHYSICAL device and is NOT the same list as `disks`, which
|
||||
is per volume. One physical drive can back several volumes or none the
|
||||
operator sees, so the two do not share a key and are not merged.
|
||||
- `diskhealth` carries BOTH agent sources and the server does not reconcile
|
||||
them. `source: "physicaldisk"` is MSFT_PhysicalDisk, the only source that
|
||||
reports NVMe at all - the classic ATA SMART class
|
||||
(`MSStorageDriver_FailurePredictStatus`) returns zero instances on an NVMe
|
||||
machine, so an agent collecting only that would report an empty health list on
|
||||
modern hardware and look like "no problems". `source: "diskdrive"` is
|
||||
Win32_DiskDrive.Status, covering ATA/SATA and hosts where the Storage
|
||||
namespace is unavailable. When both answer for one drive and disagree, both
|
||||
rows are shown; that disagreement is the signal.
|
||||
- `predictfailure` is precomputed by the agent so an alert is a boolean test
|
||||
rather than a string match against a Windows enum the server would have to
|
||||
keep in sync. `null` means the drive did not answer: unknown, NOT healthy.
|
||||
`healthstatus` keeps the human-readable source value alongside it.
|
||||
- `usercontext` is gathered by a probe run inside the signed-in operator's
|
||||
session by the `ShopDB user-context probe` scheduled task (and, opportunistically,
|
||||
by the tray), because mapped drives, user-owned listeners and idle time
|
||||
do not exist in the agent's own LocalSystem session 0. It is omitted when no
|
||||
one is signed in, which is a normal unattended state, not a fault. The
|
||||
security-context split that makes this work is normative; see section 5.
|
||||
- `ports` carries LISTENING sockets only. The full connection table is available
|
||||
on demand via the `network_connections` command instead, because established
|
||||
connections churn constantly and would change every report for no diagnostic
|
||||
gain.
|
||||
|
||||
## 3. Response
|
||||
|
||||
@@ -137,13 +262,23 @@ public sealed class EndpointReport
|
||||
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
|
||||
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
||||
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
|
||||
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
|
||||
[JsonPropertyName("bios")] public object? Bios { get; set; }
|
||||
[JsonPropertyName("memory")] public object? Memory { get; set; }
|
||||
[JsonPropertyName("processor")] public object? Processor { get; set; }
|
||||
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
|
||||
[JsonPropertyName("domain")] public string? Domain { get; set; }
|
||||
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
|
||||
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
|
||||
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
|
||||
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointEventRecord
|
||||
{
|
||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
||||
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
|
||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
||||
[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; }
|
||||
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
|
||||
@@ -163,7 +298,102 @@ public sealed class CollectorResponse
|
||||
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
||||
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
||||
|
||||
## 5. "Build site agent" settings flow (build cadence)
|
||||
## 5. Security contexts, spool handoff and log destinations (NORMATIVE)
|
||||
|
||||
Three processes run as three different principals. Confusing them is what makes
|
||||
`usercontext` silently empty, so the boundaries are fixed by contract.
|
||||
|
||||
| Process | Runs as | May write to |
|
||||
|---|---|---|
|
||||
| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\<date>-agent.log` |
|
||||
| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session, UNELEVATED (task principal `S-1-5-32-545` at `RunLevel Limited`) | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\<date>-trayagent.log` |
|
||||
| Installer (`install.ps1`, generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\<datetime>-shopdbinstall.log` and the append-only ledger `%ProgramData%\ShopDB\Logs\shopdbagent-installs.log` |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **The operator cannot write to `C:\Program Files` or to the SYSTEM-owned
|
||||
`%ProgramData%\ShopDB\EndpointAgent\logs`.** Anything running in the user's
|
||||
session MUST log under `C:\Users\ShopFloor\ShopDB\Logs`. The file log sink
|
||||
degrades to a silent no-op when it cannot create its directory, so violating
|
||||
this does not raise an error - it discards the log line and the failure
|
||||
becomes invisible.
|
||||
2. **User context is collected in the operator's own logon session**, because
|
||||
mapped drives, user-owned listeners and idle time are per-session and do not
|
||||
exist in session 0. The service MUST NOT try to descend into the session to
|
||||
collect them. The authoritative collector is the `ShopDB user-context probe`
|
||||
scheduled task, registered by the installer against the `BUILTIN\Users` group
|
||||
(`S-1-5-32-545`) at `RunLevel Limited`, triggered at logon and every 30
|
||||
minutes. The tray also probes on its own cadence and writes the same spool;
|
||||
the two are belt-and-braces and the last writer wins.
|
||||
3. **The probe MUST NOT run elevated, and a spool stamped `elevated` is
|
||||
REJECTED.** 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, usually empty, drive table. This was measured on a
|
||||
live endpoint, where the same two shares reported `Unavailable` from an
|
||||
elevated shell and `OK` from the operator's, in both `Win32_NetworkConnection`
|
||||
and `Get-SmbMapping`. `--collect-usercontext` therefore refuses to run when
|
||||
elevated (exit code 2) rather than producing a confident wrong answer, and the
|
||||
installer MUST NOT start the tray or probe as a child process - it is
|
||||
elevated, and `explorer.exe` de-elevates without changing ACCOUNT, so an
|
||||
install run by an admin produced a probe running as that admin. It MUST
|
||||
instead reach the operator's session through a scheduled task principal.
|
||||
`RunLevel Limited` on the task is load-bearing for the same reason; a task
|
||||
promoted to `Highest` reproduces the bug while still looking registered.
|
||||
4. **The probe WRITES the spool; the service only READS it.** The spool PERSISTS
|
||||
and is overwritten by each probe run. The service MUST NOT delete it: the
|
||||
collector's last-known-good cache is in-memory only, so consuming the file
|
||||
meant a service restart blanked mapped drives until the next probe cycle.
|
||||
Staleness is bounded by the in-file `collectedat` stamp (max age 90 minutes),
|
||||
not by deletion, so a probe that has stopped running still stops re-reporting
|
||||
stale drives - it just does so on a timer instead of on a read.
|
||||
5. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`,
|
||||
and writer and reader MUST derive it from the same helper.** Every shop-floor
|
||||
endpoint uses the same local `ShopFloor` account. Resolving the path through
|
||||
`WTSQueryUserToken` or through `%USERPROFILE%` is forbidden: the former is
|
||||
privileged and endpoint protection blocks it, the latter is wrong under a
|
||||
brokered or re-parented tray launch, and in both cases the writer and reader
|
||||
disagree about the location with no error on either side.
|
||||
6. **The probe MUST verify the spool exists after writing it.** A zero exit code
|
||||
from `powershell.exe` proves the interpreter started, not that a file was
|
||||
produced; success is reported only when the file is present on disk.
|
||||
7. **A spool not stamped `ShopFloor` is REJECTED.**
|
||||
collecting `username` and the service discards any spool whose stamp is
|
||||
missing or different. Drive mappings from the wrong session are worse than
|
||||
no drive mappings, so this fails loudly rather than degrading quietly.
|
||||
8. **Losing user context is never fatal.** If the `ShopFloor` profile is absent
|
||||
or unwritable, `usercontext` is omitted and telemetry, commands, inventory
|
||||
and the command channel continue unaffected.
|
||||
9. **`--collect-usercontext` MUST NOT touch privileged resources.** It runs as
|
||||
the operator and only writes into that operator's own profile, so it is
|
||||
handled before the service's bootstrap logger, fault monitor and config load.
|
||||
It MUST NOT open the SYSTEM-owned log directory, register an EventLog source,
|
||||
or read `CollectorKey` from HKLM - those are precisely the calls an EPM broker
|
||||
intercepts, and the registry read would put the collector secret in a process
|
||||
that has no need of it. It therefore also works on a host whose CollectorKey
|
||||
has not been provisioned yet.
|
||||
10. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs`
|
||||
subdirectory with `ShopFloor: Modify` before the probe first runs.
|
||||
11. **Every install appends one line to the install ledger**, including
|
||||
reinstalls and repeat installs of the same version, recording timestamp,
|
||||
host, installing user, service version, tray version, install directory and
|
||||
the user-context task state (`ok:<principal>/<runlevel>`, `UNVERIFIED:...`,
|
||||
`FAILED:...` or `skipped`). The task is the single point of failure for
|
||||
session-scoped collection, so its registration outcome MUST be recorded
|
||||
where it survives the install transcript.
|
||||
12. **Task registration MUST be verified by reading the task back**, not inferred
|
||||
from `Register-ScheduledTask` returning without error. Policy or a
|
||||
management agent can rewrite or drop a task after registration, and a task
|
||||
that exists but runs with the wrong principal or run level looks healthy in
|
||||
the scheduler UI while producing wrong data.
|
||||
13. **All log filenames use `<stamp>-<component>.log`** so service, tray and
|
||||
installer logs sort chronologically and collect with one date-leading glob.
|
||||
|
||||
The three log destinations are deliberately separate: an install is one-shot and
|
||||
elevated, the service log is a rolling runtime log, and the tray log is the only
|
||||
one written from the operator's session. Merging them makes an install
|
||||
indistinguishable from runtime behaviour when reading a failure back.
|
||||
|
||||
## 6. "Build site agent" settings flow (build cadence)
|
||||
|
||||
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
|
||||
knows its server URL and its collector key, needing no manual config on the
|
||||
@@ -190,7 +420,7 @@ Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
|
||||
per-report flag or is promoted to a site setting (strict vs self-adopting
|
||||
fleet). Default proposed: agent sends `adopt=true` on first run only.
|
||||
|
||||
## 6. UI-managed agent key (kill switch)
|
||||
## 7. UI-managed agent key (kill switch)
|
||||
|
||||
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
|
||||
`collector.ingest.endpoints`, so rotating or revoking it stops all
|
||||
@@ -215,13 +445,13 @@ The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
|
||||
first-run path before a UI key exists; it cannot be the runtime kill switch
|
||||
because container env is injected at boot and cannot be rotated live.
|
||||
|
||||
## 7. Command channel (WebUI -> agent)
|
||||
## 8. Command channel (WebUI -> agent)
|
||||
|
||||
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
|
||||
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
|
||||
no inbound port: the agent makes an outbound request the server holds open until
|
||||
a command is queued, runs it locally, and posts the result back. All three
|
||||
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
|
||||
routes reuse the same endpoints collector key (section 7) via `X-API-Key`, so
|
||||
the command channel shares the kill switch and adds no second secret.
|
||||
|
||||
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
@@ -230,6 +460,11 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
|
||||
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
|
||||
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
|
||||
- `network_connections` - return the full TCP connection table with owning
|
||||
process ids and names. Read-only diagnostic. The result is capped at 500
|
||||
connections with a `truncated` flag in `detail`, because an oversized payload
|
||||
rejected by the server returns nothing at all and a partial answer is more
|
||||
useful than a failed one. Standard reports carry listening ports only.
|
||||
|
||||
### 7.1 Claim the next command (long-poll)
|
||||
|
||||
|
||||
@@ -26,10 +26,18 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
|
||||
from .routes import endpoints_bp
|
||||
from . import agentstore
|
||||
|
||||
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
|
||||
# generous ceiling that still refuses an operator who points at a giant wrong
|
||||
# file before it is buffered fully into memory-staged storage.
|
||||
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||
# refuses an operator who points at a giant wrong file before it is buffered
|
||||
# fully into memory-staged storage.
|
||||
#
|
||||
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
|
||||
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
|
||||
# rejected outright. The upload failed on SIZE, but the operator saw only a
|
||||
# version-detection failure, because the size check returns before the PE is
|
||||
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
|
||||
# whole multipart envelope, so a cap equal to it would reject a legal file on
|
||||
# the boundary bytes alone.
|
||||
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||
@@ -69,11 +77,6 @@ def upload_agent_binary(binkey):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'binkey must be one of: agent, tray',
|
||||
http_code=400)
|
||||
if not agentstore.is_valid_version(version):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'version must be a dotted-numeric string such as 1.6.3',
|
||||
http_code=400)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if upload is None or not upload.filename:
|
||||
@@ -94,6 +97,22 @@ def upload_agent_binary(binkey):
|
||||
'uploaded file is not a Windows executable',
|
||||
http_code=400)
|
||||
|
||||
# Gate the version at the PE, not at the raw input. Product versions carry
|
||||
# semver build metadata (1.5.0+abcdef...) that is not a legal directory
|
||||
# name, so the operator's typed string is only a hint. The dotted-numeric
|
||||
# FILE version stamped in the binary's PE resource is the authority; fall
|
||||
# back to it whenever the typed value is missing or not dotted-numeric, and
|
||||
# only reject when neither yields a usable version.
|
||||
if not agentstore.is_valid_version(version):
|
||||
detected = agentstore.read_pe_file_version(data)
|
||||
if agentstore.is_valid_version(detected):
|
||||
version = detected
|
||||
else:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'version must be a dotted-numeric string such as 1.6.3',
|
||||
http_code=400)
|
||||
|
||||
result = agentstore.store_version(binkey, version, data,
|
||||
make_current=makecurrent)
|
||||
|
||||
|
||||
@@ -87,6 +87,81 @@ def _format_uptime(seconds):
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def _state_json(state, attr):
|
||||
"""Parse one of the EndpointState inventory JSON blobs.
|
||||
|
||||
These columns are written straight from the agent snapshot, so a partial
|
||||
or malformed blob from one bad check-in must not take the whole panel
|
||||
down. Anything unparseable reads as absent.
|
||||
"""
|
||||
raw = getattr(state, attr, None) if state else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_bytes(value):
|
||||
"""Byte count -> 'N.N GB'. Sizes here are disks and RAM, never sub-MB."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
||||
if abs(value) < 1024.0 or unit == 'TB':
|
||||
return '{:.1f} {}'.format(value, unit) if unit != 'B' else '{:.0f} B'.format(value)
|
||||
value /= 1024.0
|
||||
return None
|
||||
|
||||
|
||||
def _format_agent_date(value):
|
||||
"""Agent timestamps are ISO strings, not datetimes; show the date only.
|
||||
|
||||
A BIOS release date or an OS install date is only ever read at day
|
||||
resolution, and the agent sends a full 7-digit-fraction UTC stamp.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value)
|
||||
return text[:10] if len(text) >= 10 else text
|
||||
|
||||
|
||||
# A Windows event message runs to several hundred words. It is shown in an
|
||||
# expander that is closed by default, so the cap here only exists to keep one
|
||||
# pathological message from bloating the payload - not to fit a line. Blank
|
||||
# runs are collapsed but real line breaks are kept, because the body is read
|
||||
# as a block once opened.
|
||||
_EVENT_BODY_LIMIT = 4000
|
||||
|
||||
|
||||
def _event_body(event):
|
||||
"""Readable message body for one curated event, bounded and normalized.
|
||||
|
||||
Windows messages arrive padded with long runs of blank lines that render
|
||||
as ragged gaps. Runs of blank lines collapse to one and trailing spaces
|
||||
go, but single newlines survive so the text keeps its shape when opened.
|
||||
"""
|
||||
text = (getattr(event, 'eventmessage', None) or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
lines = [line.rstrip() for line in text.splitlines()]
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
if not line and cleaned and not cleaned[-1]:
|
||||
continue
|
||||
cleaned.append(line)
|
||||
text = '\n'.join(cleaned).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= _EVENT_BODY_LIMIT:
|
||||
return text
|
||||
return text[:_EVENT_BODY_LIMIT].rstrip() + '...'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint state
|
||||
# =============================================================================
|
||||
@@ -419,6 +494,60 @@ def _drift_for_state(state):
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def _dell_updates_summary(state):
|
||||
"""Parse the cached Dell Command | Update summary off an EndpointState.
|
||||
|
||||
dellupdatesjson is written on check-in from the shopdb-dellupdates agent
|
||||
utility, which reads the log the \\ShopDB-DellCommandScan task produces.
|
||||
Null means the endpoint has never reported a scan. Tolerant of a malformed
|
||||
blob so a bad cache never breaks the panel.
|
||||
"""
|
||||
raw = getattr(state, 'dellupdatesjson', None) if state else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _dell_updates_value(state):
|
||||
"""Render the informational 'Pending Dell updates' status value.
|
||||
|
||||
Informational only - this row never claims an endpoint needs attention,
|
||||
because a pending optional driver is not a support problem. Three states are
|
||||
kept distinct on purpose, and all three are VISIBLE:
|
||||
|
||||
never scanned -> 'Never scanned'. Deliberately not 'No': those are
|
||||
different facts, and claiming 'No' would tell an operator
|
||||
the machine is current when nobody has ever checked. It
|
||||
is also not hidden - a missing row reads as a broken
|
||||
panel, so the unknown state says so out loud.
|
||||
scanned clean -> 'No'.
|
||||
pending -> 'Yes (n)', with the count because one optional update and
|
||||
fourteen urgent ones warrant different urgency.
|
||||
"""
|
||||
summary = _dell_updates_summary(state)
|
||||
if not summary:
|
||||
return 'Never scanned'
|
||||
status = summary.get('status')
|
||||
if status == 'error':
|
||||
# The scan log exists but could not be read. A different problem from
|
||||
# never having scanned, and worth saying so rather than implying the
|
||||
# endpoint was never checked.
|
||||
return 'Unknown (scan unreadable)'
|
||||
if status != 'ok':
|
||||
return 'Never scanned'
|
||||
try:
|
||||
count = int(summary.get('count') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 'Unknown (scan unreadable)'
|
||||
if count <= 0:
|
||||
return 'No'
|
||||
return 'Yes ({})'.format(count)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
@@ -432,24 +561,34 @@ def panel_status(asset_id: int):
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
def field(label, value, mono=False):
|
||||
return {'label': label, 'value': value, 'mono': mono}
|
||||
def field(label, value, mono=False, fmt=None):
|
||||
entry = {'label': label, 'value': value, 'mono': mono}
|
||||
if fmt:
|
||||
entry['format'] = fmt
|
||||
return entry
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
field('Idle', _format_uptime(state.idleseconds)),
|
||||
field('Last boot',
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None),
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None,
|
||||
fmt='datetime'),
|
||||
field('CPU %', state.cpupercent),
|
||||
field('Memory %', state.memorypercent),
|
||||
field('Agent', state.agentversion, mono=True),
|
||||
field('Tray', state.trayversion, mono=True),
|
||||
field('Last reported',
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
fmt='datetime'),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
# Informational only, and appended after the null-filter above because it
|
||||
# always renders - including an explicit "Never scanned", which a hidden row
|
||||
# would leave looking like a broken panel.
|
||||
fields.append(field('Pending Dell updates', _dell_updates_value(state)))
|
||||
|
||||
# Warn-only reconciler summary: monitors site policy expects to be running
|
||||
# that the last check-in did not report as running. Surfaced as a status
|
||||
# field so an operator sees the drift on the panel; nothing is auto-started.
|
||||
@@ -465,6 +604,492 @@ def panel_status(asset_id: int):
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_hardware(asset_id: int):
|
||||
"""Keyvalue panel: processor, memory and system chassis inventory.
|
||||
|
||||
Only reported by agent 1.7.0 and later. An older agent leaves these
|
||||
columns null, so the panel yields no fields and hides itself rather than
|
||||
rendering a card full of blanks.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
processor = _state_json(state, 'processorjson') or {}
|
||||
memory = _state_json(state, 'memoryjson') or {}
|
||||
|
||||
fields = [
|
||||
{'label': 'Processor', 'value': processor.get('name')},
|
||||
{'label': 'Cores', 'value': processor.get('cores')},
|
||||
{'label': 'Logical processors', 'value': processor.get('logicalprocessors')},
|
||||
{'label': 'Max clock',
|
||||
'value': '{} MHz'.format(processor['maxclockmhz'])
|
||||
if processor.get('maxclockmhz') else None},
|
||||
{'label': 'Total memory', 'value': _format_bytes(memory.get('totalbytes'))},
|
||||
{'label': 'Free memory', 'value': _format_bytes(memory.get('freebytes'))},
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
# Wire values to operator-readable text. The wire keys are stable contract;
|
||||
# these labels are not, so they live here rather than in the agent.
|
||||
_REBOOT_REASON_LABELS = {
|
||||
'componentbasedservicing': 'servicing',
|
||||
'windowsupdate': 'Windows Update',
|
||||
'pendingfilerename': 'files staged for replacement',
|
||||
}
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/osinfo', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_osinfo(asset_id: int):
|
||||
"""Keyvalue panel: operating system, firmware and domain membership.
|
||||
|
||||
OS and BIOS are one card on purpose: both answer "what is this box
|
||||
running", and split across two cards each would be three short rows.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
osinfo = _state_json(state, 'osinfojson') or {}
|
||||
bios = _state_json(state, 'biosjson') or {}
|
||||
bootinfo = _state_json(state, 'bootinfojson') or {}
|
||||
|
||||
pendingreboot = None
|
||||
if state.pendingreboot is not None:
|
||||
pendingreboot = 'Yes' if state.pendingreboot else 'No'
|
||||
# Say WHICH signal fired when we know. A staged file rename means the
|
||||
# box is mid-servicing; a lone servicing key can sit set for weeks on a
|
||||
# machine nobody rebooted. Same "Yes", very different urgency.
|
||||
reasons = _state_json(state, 'rebootreasonsjson')
|
||||
if state.pendingreboot and isinstance(reasons, list) and reasons:
|
||||
pendingreboot = '{} ({})'.format(
|
||||
pendingreboot, ', '.join(_REBOOT_REASON_LABELS.get(r, r)
|
||||
for r in reasons if isinstance(r, str)))
|
||||
|
||||
# Only worth a row when fast startup actually applies. On a box with it
|
||||
# disabled and a cold last boot there is nothing to warn about, and a
|
||||
# permanent "Cold" row is noise on a card that is already dense.
|
||||
boottype = bootinfo.get('lastboottype')
|
||||
if boottype in (None, '', 'Cold', 'Unknown'):
|
||||
boottype = None
|
||||
elif boottype == 'Fast startup':
|
||||
# Uptime is measured from the resume, not the last true boot, so it
|
||||
# understates how long the kernel has actually been up. Said plainly
|
||||
# because this is the field that makes uptime read wrong.
|
||||
boottype = 'Fast startup (uptime not since cold boot)'
|
||||
|
||||
fields = [
|
||||
{'label': 'OS version', 'value': osinfo.get('version'), 'mono': True},
|
||||
{'label': 'Build', 'value': osinfo.get('build'), 'mono': True},
|
||||
{'label': 'Architecture', 'value': osinfo.get('architecture')},
|
||||
{'label': 'Installed', 'value': _format_agent_date(osinfo.get('installdate'))},
|
||||
{'label': 'Domain', 'value': state.domain},
|
||||
{'label': 'BIOS vendor', 'value': bios.get('vendor')},
|
||||
{'label': 'BIOS version', 'value': bios.get('version'), 'mono': True},
|
||||
{'label': 'BIOS date', 'value': _format_agent_date(bios.get('releasedate'))},
|
||||
{'label': 'Last boot', 'value': boottype},
|
||||
{'label': 'Pending reboot', 'value': pendingreboot},
|
||||
]
|
||||
fields = [f for f in fields
|
||||
if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
def _storage_rows(disks):
|
||||
"""Turn the agent's volume list into display rows.
|
||||
|
||||
freepercent is computed by the agent from 1.7.0; for a snapshot written
|
||||
by an older agent it is derived here from the byte counts so the column
|
||||
is not empty for the one field an operator actually scans.
|
||||
"""
|
||||
if not isinstance(disks, list):
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for disk in disks:
|
||||
if not isinstance(disk, dict):
|
||||
continue
|
||||
freepercent = disk.get('freepercent')
|
||||
if freepercent is None:
|
||||
size = disk.get('sizebytes') or 0
|
||||
free = disk.get('freebytes')
|
||||
if size and free is not None:
|
||||
freepercent = round(free * 100.0 / size, 1)
|
||||
rows.append({
|
||||
'drive': disk.get('drive'),
|
||||
'label': disk.get('label') or '',
|
||||
'filesystem': disk.get('filesystem'),
|
||||
'size': _format_bytes(disk.get('sizebytes')),
|
||||
'free': _format_bytes(disk.get('freebytes')),
|
||||
'freepercent': '{}%'.format(freepercent) if freepercent is not None else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
_STORAGE_COLUMNS = [
|
||||
{'key': 'drive', 'label': 'Drive'},
|
||||
{'key': 'label', 'label': 'Label'},
|
||||
{'key': 'filesystem', 'label': 'Filesystem'},
|
||||
{'key': 'size', 'label': 'Size'},
|
||||
{'key': 'free', 'label': 'Free'},
|
||||
{'key': 'freepercent', 'label': 'Free %'},
|
||||
]
|
||||
|
||||
_DISKHEALTH_COLUMNS = [
|
||||
{'key': 'model', 'label': 'Drive'},
|
||||
{'key': 'mediatype', 'label': 'Media'},
|
||||
{'key': 'bustype', 'label': 'Bus'},
|
||||
{'key': 'healthstatus', 'label': 'Health'},
|
||||
{'key': 'verdict', 'label': 'Verdict'},
|
||||
{'key': 'source', 'label': 'Source'},
|
||||
]
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/storage', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_storage(asset_id: int):
|
||||
"""Tabs panel: volume capacity and physical drive health as two sections.
|
||||
|
||||
One card because an operator looking at a full disk and an operator looking
|
||||
at a dying disk are usually the same person in the same minute. TWO
|
||||
SECTIONS because the two do not share a key: storage is per volume (C:, D:)
|
||||
and health is per physical device, and one drive can back several volumes
|
||||
or none the operator sees. Nothing is reconciled across the sections -
|
||||
this is not the mapped-drive merge, where two sources described the same
|
||||
drive and the worse status won.
|
||||
|
||||
Both sections are emitted even when empty so the section keeps its own
|
||||
empty message; a dropped section would take its tab with it and read as
|
||||
"no disks" rather than "nothing reported".
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
disks = _state_json(state, 'disksjson') if state else None
|
||||
drives = _state_json(state, 'diskhealthjson') if state else None
|
||||
return success_response({'sections': [
|
||||
{
|
||||
'label': 'Volumes',
|
||||
'columns': _STORAGE_COLUMNS,
|
||||
'rows': _storage_rows(disks),
|
||||
'empty': 'No disks reported.',
|
||||
},
|
||||
{
|
||||
'label': 'Disk health',
|
||||
'columns': _DISKHEALTH_COLUMNS,
|
||||
'rows': _diskhealth_rows(drives),
|
||||
'empty': 'No disk health reported.',
|
||||
},
|
||||
]})
|
||||
|
||||
|
||||
def _diskhealth_rows(drives):
|
||||
"""Turn the agent's disk health list into display rows, worst first.
|
||||
|
||||
Both agent sources are kept rather than merged. MSFT_PhysicalDisk is the
|
||||
only source that reports NVMe at all, and Win32_DiskDrive covers hardware
|
||||
and hosts where the Storage namespace is unavailable. When both answer for
|
||||
the same hardware, two rows that disagree is the useful signal; collapsing
|
||||
them would hide exactly the case worth looking at.
|
||||
"""
|
||||
if not isinstance(drives, list):
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for drive in drives:
|
||||
if not isinstance(drive, dict):
|
||||
continue
|
||||
predictfailure = drive.get('predictfailure')
|
||||
if predictfailure is None:
|
||||
verdict = 'Unknown'
|
||||
elif predictfailure:
|
||||
verdict = 'Failure predicted'
|
||||
else:
|
||||
verdict = 'OK'
|
||||
rows.append({
|
||||
'deviceid': drive.get('deviceid'),
|
||||
'model': drive.get('model') or '',
|
||||
'mediatype': drive.get('mediatype') or '',
|
||||
'bustype': drive.get('bustype') or '',
|
||||
'healthstatus': drive.get('healthstatus') or 'Unknown',
|
||||
'verdict': verdict,
|
||||
'source': drive.get('source') or '',
|
||||
})
|
||||
|
||||
# Failure first, then unknown, then OK. Unknown outranks OK because a drive
|
||||
# that stopped answering is a weaker claim of health than one that said OK.
|
||||
# An unrecognised verdict sorts as unknown rather than last, so a future
|
||||
# verdict string cannot bury itself under the healthy drives.
|
||||
order = {'Failure predicted': 0, 'Unknown': 1, 'OK': 2}
|
||||
rows.sort(key=lambda row: (order.get(row['verdict'], 1),
|
||||
row['deviceid'] or ''))
|
||||
return rows
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/diskhealth', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_diskhealth(asset_id: int):
|
||||
"""Physical drive health rows.
|
||||
|
||||
No longer backs a panel of its own - the Storage card renders it as a
|
||||
section. Kept as a route because it is a stable read for callers outside
|
||||
the panel renderer and removing it would break them for a UI-only change.
|
||||
|
||||
Rows are ordered so anything predicting failure sorts first: a failing
|
||||
drive must not be below the fold on a machine with several disks.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
drives = _state_json(state, 'diskhealthjson') if state else None
|
||||
return success_response(_diskhealth_rows(drives))
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/listeners', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_listeners(asset_id: int):
|
||||
"""Table panel: TCP sockets in LISTEN, with the owning process.
|
||||
|
||||
The listening set is the box's exposed surface, so it is sorted by port
|
||||
to make an unexpected one easy to spot. The full connection table is a
|
||||
deliberately on-demand command, not standard telemetry.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
ports = _state_json(state, 'portsjson') if state else None
|
||||
if not isinstance(ports, list):
|
||||
return success_response([])
|
||||
|
||||
rows = []
|
||||
for entry in ports:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
# Sample-data rows predate the agent listener collector and carry a
|
||||
# flat port/process shape; read both rather than showing them blank.
|
||||
port = entry.get('localport', entry.get('port'))
|
||||
rows.append({
|
||||
'localport': port,
|
||||
'localaddress': entry.get('localaddress'),
|
||||
'protocol': (entry.get('protocol') or 'tcp').upper(),
|
||||
'processname': entry.get('processname') or entry.get('process'),
|
||||
'processid': entry.get('processid'),
|
||||
})
|
||||
rows.sort(key=lambda r: (r['localport'] is None, r['localport']))
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
def _drive_letter(value):
|
||||
"""Normalise a drive to its bare letter so the two sources can be paired.
|
||||
|
||||
net use reports 'Z:' and Get-SmbMapping reports 'Z:' or occasionally a
|
||||
trailing separator, so compare on the letter alone.
|
||||
"""
|
||||
text = (value or '').strip().rstrip('\\/')
|
||||
return text.upper() or None
|
||||
|
||||
|
||||
# Worst-first. The panel shows the worse of the two sources for a drive, so a
|
||||
# share that one source calls OK and the other calls Unavailable is reported as
|
||||
# Unavailable: a drive that is broken in either view is broken for the operator.
|
||||
#
|
||||
# WARNING - ranks must stay DISTINCT across statuses that can genuinely differ.
|
||||
# _worse_drive_status resolves by min(), which breaks a tie by argument order,
|
||||
# so two statuses sharing a rank make the verdict depend on which SOURCE
|
||||
# happened to report which - the same drive reads healthy or broken based on
|
||||
# enumeration order. Deliberate synonyms are fine (disconnected/unavailable,
|
||||
# ok/connected) because either answer is the same answer. Adding a NEW status at
|
||||
# an existing rank is not. test_which_source_reported_a_status_does_not_change
|
||||
# _the_verdict in tests/test_plugins/test_endpoints_mapped_drives.py asserts
|
||||
# both directions of each pair and is what catches this; the single-direction
|
||||
# tests stay green through it.
|
||||
_DRIVE_STATUS_RANK = {
|
||||
'unavailable': 0,
|
||||
'disconnected': 0,
|
||||
'paused': 1,
|
||||
'reconnecting': 2,
|
||||
'ok': 3,
|
||||
'connected': 3,
|
||||
}
|
||||
|
||||
|
||||
def _worse_drive_status(left, right):
|
||||
"""Return whichever status is worse, preferring a known one over None.
|
||||
|
||||
An unrecognised status ranks as Reconnecting, deliberately below OK: a
|
||||
status we cannot interpret must not be assumed healthy.
|
||||
"""
|
||||
candidates = [s for s in (left, right) if s]
|
||||
if not candidates:
|
||||
return None
|
||||
unknown_rank = _DRIVE_STATUS_RANK['reconnecting']
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda s: _DRIVE_STATUS_RANK.get(str(s).strip().lower(), unknown_rank)
|
||||
)
|
||||
|
||||
|
||||
def _mapped_drive_rows(context):
|
||||
"""Merge the two mapped-drive views into one structured row per letter.
|
||||
|
||||
Windows exposes mapped drives through Win32_NetworkConnection (the 'net use'
|
||||
table) and through Get-SmbMapping, and they do not always agree - a share can
|
||||
be Unavailable in one and OK or absent in the other. The probe collects both
|
||||
without reconciling them, so the disagreement survives to here.
|
||||
|
||||
A drive present in only one source is still a drive: it is reported with the
|
||||
status that source gave it, rather than dropped for lack of a second opinion.
|
||||
|
||||
WARNING - three behaviours here are load-bearing and easy to break while
|
||||
"simplifying". They are covered by tests/test_plugins/test_endpoints_mapped
|
||||
_drives.py; if you touch this, read those first:
|
||||
|
||||
1. Worse-status-wins. Never report the healthier of the two sources. A
|
||||
drive that either source calls Unavailable IS unavailable to the
|
||||
operator, and rendering it OK sends someone to a share that is down.
|
||||
2. Single-source drives are kept, not dropped. Get-SmbMapping is absent on
|
||||
some builds and session types; requiring agreement would silently hide
|
||||
every mapped drive on those machines.
|
||||
3. Unknown statuses rank as Reconnecting, not as OK. An unrecognised
|
||||
string must never be optimistically treated as healthy.
|
||||
|
||||
Changing any of these makes a broken drive look fine, which is a silent
|
||||
failure - the card stays green and nobody is told.
|
||||
"""
|
||||
drives = {}
|
||||
|
||||
netuse = context.get('netuse')
|
||||
if isinstance(netuse, list):
|
||||
for entry in netuse:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
letter = _drive_letter(entry.get('localname') or entry.get('local'))
|
||||
if not letter:
|
||||
continue
|
||||
drives[letter] = {
|
||||
'remote': entry.get('remotename') or entry.get('remote') or '',
|
||||
'status': entry.get('status'),
|
||||
}
|
||||
|
||||
smbmapping = context.get('smbmapping')
|
||||
if isinstance(smbmapping, list):
|
||||
for entry in smbmapping:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
letter = _drive_letter(entry.get('localpath'))
|
||||
if not letter:
|
||||
continue
|
||||
existing = drives.get(letter)
|
||||
if existing:
|
||||
existing['status'] = _worse_drive_status(
|
||||
existing.get('status'), entry.get('status'))
|
||||
existing['remote'] = existing['remote'] or entry.get('remotepath') or ''
|
||||
else:
|
||||
drives[letter] = {
|
||||
'remote': entry.get('remotepath') or '',
|
||||
'status': entry.get('status'),
|
||||
}
|
||||
|
||||
rows = []
|
||||
for letter in sorted(drives):
|
||||
drive = drives[letter]
|
||||
# A drive neither source gave a status for is Unknown, never blank. An
|
||||
# unannotated row reads as healthy, so a failed check would render as a
|
||||
# working drive - the one outcome this card must never produce.
|
||||
status = (drive.get('status') or '').strip() or 'Unknown'
|
||||
healthy = status.lower() in ('ok', 'connected')
|
||||
rows.append({
|
||||
'drive': letter,
|
||||
'remote': (drive.get('remote') or '').strip(),
|
||||
'statuslabel': status,
|
||||
# Only unhealthy drives are coloured. Green on every working drive
|
||||
# makes the broken one just another badge in a row of badges; the
|
||||
# card's job is to make the exception findable at a glance.
|
||||
'statecolor': 'green' if healthy else 'red',
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _mapped_drives(context):
|
||||
"""The mapped drives as one-line labels, for the user context card.
|
||||
|
||||
Built on _mapped_drive_rows so the summary line and the card can never
|
||||
disagree about a drive's status.
|
||||
"""
|
||||
labels = []
|
||||
for row in _mapped_drive_rows(context):
|
||||
label = '{} {}'.format(row['drive'], row['remote']).strip()
|
||||
# Only the problem states are annotated. Tagging every healthy drive
|
||||
# with "(OK)" would bury the one that is not.
|
||||
if row['statecolor'] != 'green':
|
||||
label = '{} ({})'.format(label, row['statuslabel'])
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def _mapped_drives_unhealthy_first(rows):
|
||||
"""Order drive rows so anything not healthy sorts first.
|
||||
|
||||
A user with a dozen mappings should not have to hunt for the broken one.
|
||||
Sorting only on health keeps it stable: drive-letter order survives within
|
||||
each group, so the list does not reshuffle between refreshes.
|
||||
"""
|
||||
return sorted(rows, key=lambda row: row['statecolor'] == 'green')
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/mappeddrives', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_mappeddrives(asset_id: int):
|
||||
"""List panel: mapped network drives with a health badge each.
|
||||
|
||||
Session-scoped: mapped drives do not exist in the agent's session 0, so
|
||||
these come from the interactive user probe. Nothing reported when nobody
|
||||
is signed in, which is a valid state and not a failure.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
context = _state_json(state, 'usercontextjson') if state else None
|
||||
if not isinstance(context, dict) or not context:
|
||||
return success_response([])
|
||||
return success_response(_mapped_drives_unhealthy_first(_mapped_drive_rows(context)))
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/usercontext', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_usercontext(asset_id: int):
|
||||
"""Keyvalue panel: session-scoped data from the interactive user probe.
|
||||
|
||||
Mapped drives and user-owned listeners do not exist in the agent's
|
||||
session 0, so they are collected by a probe run in the logged-in user's
|
||||
session. Nothing is reported when no one is signed in, which is a valid
|
||||
state and not a failure.
|
||||
|
||||
Mapped drives arrive from two sources that can disagree; the worse of the
|
||||
two statuses is shown so a half-broken share is never rendered as healthy.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
context = _state_json(state, 'usercontextjson')
|
||||
if not isinstance(context, dict) or not context:
|
||||
return success_response({'fields': []})
|
||||
|
||||
drives = _mapped_drives(context)
|
||||
|
||||
listeners = context.get('userlisteners')
|
||||
listenercount = len(listeners) if isinstance(listeners, list) else None
|
||||
|
||||
fields = [
|
||||
{'label': 'Probed user', 'value': context.get('username')},
|
||||
{'label': 'Collected', 'value': context.get('collectedat')},
|
||||
{'label': 'Idle', 'value': _format_uptime(context.get('idleseconds'))},
|
||||
{'label': 'Mapped drives',
|
||||
'value': ', '.join(drives) if drives else None, 'mono': True},
|
||||
{'label': 'User listeners', 'value': listenercount},
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_telemetry_meta(asset_id: int):
|
||||
@@ -723,10 +1348,18 @@ def panel_activity(asset_id: int):
|
||||
|
||||
rows = []
|
||||
for event in events:
|
||||
# The provider name is what distinguishes two events sharing an ID,
|
||||
# and the message body is the line an operator actually reads. Both
|
||||
# are already stored; the feed just was not showing them.
|
||||
source = event.eventsource or event.eventchannel
|
||||
detail = 'Windows event {}'.format(event.eventid)
|
||||
if source:
|
||||
detail = '{} - {}'.format(detail, source)
|
||||
rows.append({
|
||||
'title': event_title(event.eventid),
|
||||
'severity': event_severity(event.eventid),
|
||||
'detail': 'Windows event {}'.format(event.eventid),
|
||||
'detail': detail,
|
||||
'body': _event_body(event),
|
||||
'eventtime': event.eventtime,
|
||||
})
|
||||
|
||||
@@ -742,7 +1375,9 @@ def panel_activity(asset_id: int):
|
||||
'title': '{}{}'.format(label, target),
|
||||
# A failed command is the one an operator needs to notice.
|
||||
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
|
||||
'detail': '{}{}'.format((command.status or 'queued').title(), who),
|
||||
'detail': '{}{}{}'.format('Command - ', (command.status or 'queued').title(), who),
|
||||
# A failed command's reason is the whole point of noticing it.
|
||||
'body': (command.resultmessage or '').strip() or None,
|
||||
'eventtime': command.createdat,
|
||||
})
|
||||
|
||||
@@ -768,10 +1403,37 @@ def panel_activity(asset_id: int):
|
||||
'severity': r['severity'],
|
||||
'severitycolor': colours.get(r['severity'], 'gray'),
|
||||
'detail': r['detail'],
|
||||
'body': r['body'],
|
||||
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||
} for r in rows[:15]])
|
||||
|
||||
|
||||
def _link_label(speedbps, fullduplex):
|
||||
"""Render NIC link speed and duplex as one cell.
|
||||
|
||||
Null speed means the agent could not read link state OR the adapter is
|
||||
disconnected - verified on a live box that a disconnected NIC returns empty
|
||||
values. Either way we do not know, so it reads '-' rather than 0 Mb.
|
||||
|
||||
Half duplex is called out explicitly because that is the actionable case: a
|
||||
NIC that negotiated half duplex on a switch port is a cable or port fault
|
||||
that otherwise presents as "the machine is slow sometimes". Full duplex is
|
||||
the normal case and gets no annotation, so the only thing standing out in
|
||||
the column is the thing worth looking at.
|
||||
"""
|
||||
if not speedbps:
|
||||
return '-'
|
||||
|
||||
if speedbps >= 1000000000:
|
||||
speed = '{:g} Gb'.format(speedbps / 1000000000)
|
||||
else:
|
||||
speed = '{:g} Mb'.format(speedbps / 1000000)
|
||||
|
||||
if fullduplex is False:
|
||||
return '{} half duplex'.format(speed)
|
||||
return speed
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
@@ -809,6 +1471,99 @@ def panel_interfaces(asset_id: int):
|
||||
# A gatewayless NIC with an address is the machine link; the gateway
|
||||
# NIC is the corporate LAN.
|
||||
'role': 'Machine link' if (ipv4 and not gateway) else 'LAN',
|
||||
'link': _link_label(entry.get('linkspeedbps'),
|
||||
entry.get('fullduplex')),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
# Columns for the two cards carved out of the old hand-written Network card on
|
||||
# the Details tab. That card listed current addresses with a Primary badge and
|
||||
# nothing else, which undersold what the rows actually hold.
|
||||
_IPHISTORY_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'macaddress', 'label': 'MAC'},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen'},
|
||||
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||
]
|
||||
|
||||
|
||||
def _isostamp(value):
|
||||
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||
|
||||
Formatting is left to the client so it can render in the viewer's locale
|
||||
and timezone; returning a preformatted string here would pin every reader
|
||||
to the server's.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
def _communication_rows(asset_id: int):
|
||||
"""Every Communication row for an asset, newest change first.
|
||||
|
||||
These rows are an ACCIDENTAL history rather than a designed one.
|
||||
_record_interfaces in the computers plugin matches an incoming NIC on MAC
|
||||
then on IP and inserts when neither matches, and it never deletes - so an
|
||||
address the machine has not held for a year is still sitting here next to
|
||||
the current one. That is genuinely useful (it answers "what was this box on
|
||||
before?"), so it is surfaced as history rather than cleaned away.
|
||||
"""
|
||||
from shopdb.api import Communication
|
||||
|
||||
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
|
||||
# Rows that never got a modifieddate sink to the bottom on every dialect.
|
||||
return (Communication.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc())
|
||||
.all())
|
||||
|
||||
|
||||
def _iphistory_status(comm):
|
||||
"""What this row IS, in the vocabulary somebody troubleshooting would use.
|
||||
|
||||
Primary is the address the collector last picked as this box's main one.
|
||||
APIPA is called out by name because a 169.254.x.x address is not an address
|
||||
the machine was given - it is what Windows assigns when DHCP failed, so its
|
||||
presence in the list is a record of a past network fault, not a past
|
||||
subnet.
|
||||
"""
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if address.startswith('169.254.'):
|
||||
return 'APIPA (no DHCP)'
|
||||
if comm.isprimary:
|
||||
return 'Primary'
|
||||
if comm.ismachinenetwork:
|
||||
return 'Machine network'
|
||||
return 'Previous'
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_iphistory(asset_id: int):
|
||||
"""Table panel: every address this asset has held, newest change first.
|
||||
|
||||
Replaces the hand-written Network card that used to sit on the Details tab
|
||||
and show only the current addresses. Dates come from the row's own
|
||||
lifecycle columns, so they are labelled for what they are: 'Last changed'
|
||||
is when a field on the row last moved, NOT when the address was last seen.
|
||||
An address the machine still holds and reports unchanged does not update
|
||||
modifieddate, so calling that column 'Last seen' would be a lie the data
|
||||
cannot support.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'macaddress': (comm.macaddress or '').strip() or '-',
|
||||
'status': _iphistory_status(comm),
|
||||
'firstseen': _isostamp(comm.createddate),
|
||||
'lastchanged': _isostamp(comm.modifieddate),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ _BASE_APPSETTINGS = {
|
||||
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
|
||||
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
|
||||
},
|
||||
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
|
||||
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
|
||||
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
|
||||
# a site can narrow or widen the set without an agent rebuild. Anything not
|
||||
# matched fails closed.
|
||||
'approvedtaskpaths': [],
|
||||
}
|
||||
|
||||
_README = """ShopDB endpoint agent (site build)
|
||||
@@ -217,7 +223,19 @@ param(
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
[string] $CertFileName = {certfile},
|
||||
[switch] $NoTray
|
||||
[switch] $NoTray,
|
||||
|
||||
# Scheduled task that runs the user-context probe INSIDE the operator's own
|
||||
# logon session. Mapped drives are per logon token, so this is the only
|
||||
# reliable way to collect them; see the registration block below.
|
||||
[string] $UserContextTaskName = "ShopDB user-context probe",
|
||||
[int] $UserContextTaskMinutes = 30,
|
||||
|
||||
# The account the probe task runs as. Must match the account whose profile
|
||||
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
|
||||
# the probe writes session data the service never looks at.
|
||||
[string] $UserContextTaskUser = "ShopFloor",
|
||||
[switch] $NoUserContextTask
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -362,10 +380,175 @@ if (-not $NoTray) {{
|
||||
}}
|
||||
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
||||
|
||||
# Launch the tray once now so it appears without waiting for the next
|
||||
# logon. Single-instance guard makes a duplicate launch a no-op.
|
||||
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
|
||||
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
|
||||
# Deliberately NOT launched here. This installer runs elevated, so
|
||||
# Start-Process would hand the tray an ADMIN token, and drive letters
|
||||
# are per logon token: the probe would then enumerate the installing
|
||||
# admin's drive table, not the operator's. Measured on a live endpoint,
|
||||
# the same two shares reported Unavailable from the elevated shell and
|
||||
# OK from the operator's own session, in both Win32_NetworkConnection
|
||||
# and Get-SmbMapping. Launching via explorer.exe does not fix this
|
||||
# either - it de-elevates but does NOT change account.
|
||||
#
|
||||
# The Run key above is the correct autostart: it fires at a genuine
|
||||
# logon, in the operator's own session. Collection no longer depends on
|
||||
# the tray at all - the scheduled task below owns it.
|
||||
Write-Host " tray will start at next logon (not launched from this elevated install)."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
|
||||
# USER. This is what makes mapped drives, user listeners and idle time
|
||||
# collectable: it is the only mechanism that starts a process in the operator's
|
||||
# own session with the operator's own token.
|
||||
#
|
||||
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
|
||||
# token is precisely what sees the operator's mapped drives.
|
||||
#
|
||||
# UserId, NOT GroupId. A group-principal task is registered against the group
|
||||
# rather than against any one account, and a member of that group cannot invoke
|
||||
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
|
||||
# denied, so the task only ever ran on its own trigger. That is exactly the bug
|
||||
# where "Refresh Status" appeared to do nothing. Registering to the specific
|
||||
# account makes the task startable by that account, which is what both the tray
|
||||
# and any manual retry depend on. The group form is kept only as a fallback for
|
||||
# a host where the account cannot be resolved - it still collects on a timer,
|
||||
# it just cannot be kicked on demand.
|
||||
$taskState = "skipped"
|
||||
if (-not $NoUserContextTask) {{
|
||||
Write-Host " registering user-context probe task ..."
|
||||
$taskAgentExe = Join-Path $InstallDir "shopdbagent.exe"
|
||||
$taskRegistered = $false
|
||||
$taskDetail = "no principal accepted"
|
||||
try {{
|
||||
$taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext"
|
||||
# AtLogOn covers the normal case. The repeating trigger matters just as
|
||||
# much: AtLogOn does NOT fire for a session that is already signed in,
|
||||
# which is every install onto a machine someone is currently using.
|
||||
$taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn)
|
||||
if ($UserContextTaskMinutes -gt 0) {{
|
||||
$taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes)
|
||||
}}
|
||||
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
|
||||
|
||||
# Try the machine-qualified account first, then the bare name. Interactive
|
||||
# LogonType so the task joins the existing interactive session instead of
|
||||
# creating a separate one with its own (empty) drive table.
|
||||
$taskCandidates = @(
|
||||
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
|
||||
@{{ Kind = "user"; Id = $UserContextTaskUser }},
|
||||
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
|
||||
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
|
||||
)
|
||||
foreach ($candidate in $taskCandidates) {{
|
||||
try {{
|
||||
if ($candidate.Kind -eq "user") {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
|
||||
}}
|
||||
else {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
|
||||
}}
|
||||
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
|
||||
$taskRegistered = $true
|
||||
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
|
||||
break
|
||||
}}
|
||||
catch {{ $taskDetail = $_.Exception.Message }}
|
||||
}}
|
||||
}}
|
||||
catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }}
|
||||
|
||||
if ($taskRegistered) {{
|
||||
# Read the task back rather than trusting Register-ScheduledTask to have
|
||||
# returned cleanly: policy or a management agent can rewrite or drop a
|
||||
# task after registration, and a task that exists but runs elevated
|
||||
# looks healthy in the scheduler UI while producing wrong data.
|
||||
$verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue
|
||||
if (-not $verified) {{
|
||||
$taskState = "UNVERIFIED:task not found after registration"
|
||||
Write-Warning " user-context task registered but cannot be read back."
|
||||
}}
|
||||
elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{
|
||||
$taskState = "UNVERIFIED:elevated"
|
||||
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
|
||||
}}
|
||||
elseif ($verified.Principal.GroupId) {{
|
||||
# Registered, will collect on its triggers, but cannot be started on
|
||||
# demand by the operator - so "Refresh Status" will fall back to the
|
||||
# timer. Surfaced rather than reported as ok, because the symptom
|
||||
# (stale session data that never responds to a refresh) is otherwise
|
||||
# indistinguishable from a broken probe.
|
||||
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
|
||||
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
|
||||
}}
|
||||
else {{
|
||||
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
|
||||
Write-Host " user-context task registered and verified: $taskState"
|
||||
}}
|
||||
|
||||
# AtLogOn will not fire for an already-signed-in session, so kick it once
|
||||
# rather than leaving a fresh install blind until the repeat interval.
|
||||
try {{
|
||||
Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop
|
||||
Write-Host " user-context task started once for the current session."
|
||||
}}
|
||||
catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }}
|
||||
}}
|
||||
else {{
|
||||
# Not fatal: the service still checks in, only the session-scoped fields
|
||||
# are missing, and the ledger records why.
|
||||
$taskState = "FAILED:$taskDetail"
|
||||
Write-Warning " could not register the user-context task: $taskDetail"
|
||||
Write-Warning " mapped drives and idle time will not be collected."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 2c. Start the tray in the operator's session.
|
||||
#
|
||||
# The Run key registered above only fires at LOGON, so installing onto a machine
|
||||
# someone is already signed in to leaves no tray until they sign out and back in.
|
||||
# The installer cannot start it directly - it is elevated, and the tray must run
|
||||
# unelevated in the user's own session - so we borrow the same mechanism that
|
||||
# solves this for the probe: a transient scheduled task with a Limited group
|
||||
# principal, run once and then deleted.
|
||||
#
|
||||
# Registered without a trigger; it exists only to be invoked immediately. The
|
||||
# tray is single-instance, so if one is somehow already running this is a no-op.
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$launchTaskName = "ShopDB tray first launch"
|
||||
try {{
|
||||
$launchExe = Join-Path $InstallDir $TrayExeName
|
||||
$launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir
|
||||
$launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$launched = $false
|
||||
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
|
||||
try {{
|
||||
$launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
|
||||
Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null
|
||||
Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop
|
||||
$launched = $true
|
||||
break
|
||||
}}
|
||||
catch {{ }}
|
||||
}}
|
||||
|
||||
# Give the task a moment to spawn the tray before removing it: deleting a
|
||||
# task that is still starting can kill the process it just launched.
|
||||
if ($launched) {{
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " tray started in the operator's session."
|
||||
}}
|
||||
else {{
|
||||
Write-Warning " could not start the tray now; it will start at next logon via the Run key."
|
||||
}}
|
||||
|
||||
# Transient by design: this is a one-shot launcher, not an autostart.
|
||||
# The Run key owns autostart, and leaving this behind would mean two
|
||||
# things racing to start the same single-instance tray at every logon.
|
||||
try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }}
|
||||
catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }}
|
||||
}}
|
||||
catch {{
|
||||
Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon."
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -397,15 +580,22 @@ Write-Host " service is running." -ForegroundColor Green
|
||||
|
||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$trayOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
|
||||
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
|
||||
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
|
||||
# Report only; never relaunch from here. This block used to Start-Process the
|
||||
# tray when it was not found running, which would hand it this installer's
|
||||
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
|
||||
# above is the only place the tray is started during install, and it starts
|
||||
# it correctly - unelevated, in the operator's session.
|
||||
$trayFound = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }}
|
||||
Start-Sleep -Seconds 2
|
||||
}}
|
||||
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
|
||||
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
|
||||
if ($trayFound) {{
|
||||
Write-Host " tray is running." -ForegroundColor Green
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "tray did not confirm running; it will start at next logon via the Run key."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
||||
@@ -432,6 +622,29 @@ if (Test-Path $installSelf) {{
|
||||
}}
|
||||
|
||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||
|
||||
# Append one line per install, including reinstalls and repeat installs of the
|
||||
# same version. The user-context task state is recorded because it is the single
|
||||
# point of failure for session-scoped collection: if mapped drives go missing
|
||||
# later, this answers "was the task ever registered on this host, and as what"
|
||||
# without needing the endpoint back. The ledger is a record, not a gate - a
|
||||
# failure to append must never fail an otherwise-good install.
|
||||
try {{
|
||||
$ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs"
|
||||
New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null
|
||||
$ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log"
|
||||
$ledgerAgentVersion = "unknown"
|
||||
if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }}
|
||||
$ledgerTrayVersion = "not-installed"
|
||||
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
|
||||
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
|
||||
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
|
||||
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
|
||||
Write-Host "Install ledger: $ledgerPath"
|
||||
Write-Host " $ledgerLine" -ForegroundColor DarkGray
|
||||
}}
|
||||
catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }}
|
||||
|
||||
if (Test-Path $uninstaller) {{
|
||||
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
||||
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
||||
|
||||
@@ -77,12 +77,36 @@ COMMAND_ALLOWLIST = {
|
||||
'label': 'Collect now',
|
||||
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
||||
},
|
||||
'network_connections': {
|
||||
'label': 'Show network connections',
|
||||
'description': 'Return the full TCP connection table with owning processes. '
|
||||
'Read-only diagnostic; the standard report carries only '
|
||||
'listening ports because established connections churn.',
|
||||
},
|
||||
'scheduledtask_run': {
|
||||
'label': 'Run scheduled task',
|
||||
'description': 'Start an already-registered Windows scheduled task on '
|
||||
'the endpoint by its task path (for example '
|
||||
'\\ReportIP). The agent does not create, modify, or '
|
||||
'define tasks - it only starts one that already exists, '
|
||||
'and only when the path clears the agent-side approved '
|
||||
'task allowlist. The task runs in whatever context its '
|
||||
'own registered definition specifies, not the agent\'s.',
|
||||
'targetkind': 'scheduledtask',
|
||||
},
|
||||
'run_script': {
|
||||
'label': 'Run script',
|
||||
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
|
||||
'fetches the script body by digest using its collector '
|
||||
'credential and runs it in the script\'s configured '
|
||||
'context (system or the logged-on user).',
|
||||
'context (system or the logged-on user). CAUTION: a '
|
||||
'script sent this way runs as SYSTEM unless its '
|
||||
'descriptor pins the user context, so this is arbitrary '
|
||||
'code execution on the endpoint by design. Grant '
|
||||
'endpoints.deploy only to the most trusted operators, '
|
||||
'and gate the UI behind an explicit confirm the way '
|
||||
'system_restart is gated (see confirmRestart in '
|
||||
'PCDetail.vue).',
|
||||
'targetkind': 'script',
|
||||
},
|
||||
'update_app': {
|
||||
@@ -96,11 +120,76 @@ COMMAND_ALLOWLIST = {
|
||||
}
|
||||
|
||||
|
||||
# Dell Command | Update: TWO utilities, deliberately two separate actions.
|
||||
# A scan reports what is available and changes nothing; an update installs and
|
||||
# can reboot. Collapsing them into one parameterized action would make the
|
||||
# destructive one reachable by editing a field, so they stay distinct and the
|
||||
# UI can gate them differently.
|
||||
#
|
||||
# Both are driven through scheduledtask_run against ShopDB-owned task
|
||||
# definitions (\ShopDB-DellCommandScan and \ShopDB-DellCommandUpdate, already in
|
||||
# ScheduledTaskPolicy's shipped defaults) rather than a bespoke verb. The task
|
||||
# definition - not the agent - pins the dcu-cli.exe path, its arguments, and its
|
||||
# output path, which is why no new agent code is needed to run them.
|
||||
#
|
||||
# OUTPUT PATH CONVENTION. A ShopDB-initiated scan MUST write its report under
|
||||
# %ProgramData%\ShopDB\EndpointAgent\ (the same SYSTEM-owned root as the agent
|
||||
# logs), NOT to Dell's default location. Corporate-managed DCU tasks write to
|
||||
# the default; keeping ShopDB's output separate is the only thing that makes
|
||||
# "did ShopDB ask for this scan, or did corporate" answerable when the results
|
||||
# are collected. The path lives in the task definition and is generated
|
||||
# server-side, so it can change without touching the agent.
|
||||
#
|
||||
# STILL TO DO - the results collector. Firing DCU is the easy half. schtasks
|
||||
# /run returns as soon as the task STARTS, so a successful scheduledtask_run
|
||||
# means "scan started", never "scan finished" - the command result cannot carry
|
||||
# findings. Reporting available updates back into ShopDB needs a collector that
|
||||
# reads the report from the path above and folds it into the telemetry payload.
|
||||
# That is a separate change from this one; do not infer from a green command
|
||||
# result that the data path exists.
|
||||
|
||||
|
||||
# Reserved seam - one-click mapped-drive remediation. NOT implemented; this is a
|
||||
# placement note so the next person adds it here rather than inventing a new
|
||||
# transport. When it lands it will re-establish a Disconnected mapping surfaced
|
||||
# by the user-context card:
|
||||
#
|
||||
# 'remap_drive': {
|
||||
# 'label': 'Reconnect mapped drive',
|
||||
# 'description': 'Re-establish a disconnected network drive mapping.',
|
||||
# 'targetkind': 'drive',
|
||||
# },
|
||||
#
|
||||
# It MUST execute in the interactive USER session. A SYSTEM-context remap creates
|
||||
# a mapping in SYSTEM's own drive table that the operator cannot see, so the card
|
||||
# would keep reporting the drive as broken while the "fix" silently succeeded
|
||||
# somewhere nobody is looking. An ELEVATED remap fails the same way, because UAC
|
||||
# token separation gives the elevated context a different drive table - the same
|
||||
# reason the probe itself runs unelevated from the tray.
|
||||
#
|
||||
# CommandExecutor.RunScriptInUserSession is the existing path for this.
|
||||
|
||||
|
||||
# Actions that demand a permission stricter than the base endpoints.command.
|
||||
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
|
||||
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
|
||||
# NOTE FOR THE ROLES/PERMISSIONS WORK: scheduledtask_run is deliberately mapped
|
||||
# onto the EXISTING endpoints.deploy grant rather than introducing a new one, so
|
||||
# the wire is pre-built and needs no migration. Be aware this WIDENS what
|
||||
# endpoints.deploy means: it previously covered run_script + update_app, and now
|
||||
# also covers starting approved scheduled tasks. If that bundle is too coarse,
|
||||
# the clean split is a separate endpoints.corptools permission mapped here - no
|
||||
# other code needs to change, because the enqueue route reads this table.
|
||||
#
|
||||
# Suggested operator-facing wording for the combined grant:
|
||||
# "Caution: Operator can run scheduled tasks, update applications, and affect
|
||||
# system-level changes to the endpoint."
|
||||
# That wording is accurate for scheduledtask_run and update_app. It UNDERSTATES
|
||||
# run_script, which is arbitrary SYSTEM code execution - if run_script stays in
|
||||
# this bundle, say so plainly in the role description.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
'scheduledtask_run': 'endpoints.deploy',
|
||||
'run_script': 'endpoints.deploy',
|
||||
'update_app': 'endpoints.deploy',
|
||||
}
|
||||
@@ -215,6 +304,24 @@ def monitor_id_from_action(action):
|
||||
return None
|
||||
|
||||
|
||||
# Actions gated by ROLE ONLY, never by per-endpoint Configuration Policy.
|
||||
#
|
||||
# These are the Corporate Tools actions. They start already-registered,
|
||||
# agent-allowlisted tasks and are not a per-machine configuration concern, so a
|
||||
# policy that never mentions them must not remove them. Policy exists to shape
|
||||
# what a given PC TYPE exposes (which monitors, which service primitives); it is
|
||||
# not the authority on whether an admin may run a corporate tool.
|
||||
#
|
||||
# Without this exemption the intersection at the end of ALLOWED_ACTIONS_FOR
|
||||
# silently drops these actions on any endpoint whose effective policy is
|
||||
# non-empty, producing "Action not permitted by policy for this endpoint" even
|
||||
# for a caller holding endpoints.deploy. The real gate for these stays
|
||||
# ELEVATED_ACTION_PERMISSIONS (role), enforced in the enqueue route.
|
||||
POLICY_EXEMPT_ACTIONS = frozenset({
|
||||
'scheduledtask_run',
|
||||
})
|
||||
|
||||
|
||||
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
"""The action names an endpoint may run.
|
||||
|
||||
@@ -223,8 +330,9 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
result is narrowed to the actions the endpoint's effective policy permits -
|
||||
this is the allowlist-gating half of the policy design. With the feature off
|
||||
(or no assetid), it returns the full base allowlist, so nothing changes until
|
||||
a site opts in. Policy resolution is imported lazily to keep this module free
|
||||
of a hard dependency on the computers plugin.
|
||||
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away;
|
||||
they are gated by role alone. Policy resolution is imported lazily to keep
|
||||
this module free of a hard dependency on the computers plugin.
|
||||
"""
|
||||
base = set(COMMAND_ALLOWLIST.keys())
|
||||
if assetid is None:
|
||||
@@ -247,6 +355,9 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||
# "not permitted by policy". Expand each monitor:<id> to its kind's control
|
||||
# actions before intersecting.
|
||||
allowed = allowed | _monitor_control_actions(allowed)
|
||||
# Role-gated actions are added back unconditionally: policy narrows the
|
||||
# per-endpoint surface, but it does not own the corporate tools.
|
||||
allowed = allowed | POLICY_EXEMPT_ACTIONS
|
||||
# Never widen past the code-level allowlist: policy can only gate within it.
|
||||
return base & allowed
|
||||
|
||||
|
||||
@@ -24,7 +24,14 @@ EVENT_CATALOG = {
|
||||
55: ('File system corruption', 'critical', 'System'),
|
||||
7011: ('Service timeout', 'critical', 'System'),
|
||||
10016: ('DCOM permission error', 'warning', 'System'),
|
||||
# Windows Error Reporting trio on the Application channel. 1000 is the
|
||||
# faulting-application record (app name, version, faulting module, exception
|
||||
# code) and is the one that says what actually broke; 1001 is the WER bucket
|
||||
# record Windows writes alongside it; 1002 is a hang rather than a crash, so
|
||||
# it is a warning - the app is unresponsive, not gone.
|
||||
1000: ('Application error', 'critical', 'Application'),
|
||||
1001: ('Application crash', 'critical', 'Application'),
|
||||
1002: ('Application hang', 'warning', 'Application'),
|
||||
}
|
||||
|
||||
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.
|
||||
|
||||
@@ -319,9 +319,9 @@
|
||||
<div
|
||||
v-for="binary in binaries"
|
||||
:key="binary.key"
|
||||
class="binary-block"
|
||||
class="card binary-block"
|
||||
>
|
||||
<h4>{{ binary.label }}</h4>
|
||||
<h4 class="binary-title">{{ binary.label }}</h4>
|
||||
<p class="field-hint">
|
||||
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
||||
binaries available for your "Build Site Agent" install package.
|
||||
@@ -355,7 +355,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="binary.busy || !binary.version || !binary.file"
|
||||
:disabled="binary.busy || binary.detecting || !binary.file"
|
||||
@click="uploadBinary(binary.key)"
|
||||
>
|
||||
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
|
||||
@@ -370,8 +370,9 @@
|
||||
Upload legacy build for rollback
|
||||
</label>
|
||||
<p class="field-warning">
|
||||
WARNING: confirm agent version before uploading from the EXE
|
||||
properties and enter the version number.
|
||||
The version is read from the EXE itself. Leave this blank to use the
|
||||
binary's own file version, or enter a dotted-numeric override only
|
||||
when the build carries no readable version.
|
||||
</p>
|
||||
<div class="binary-upload">
|
||||
<input
|
||||
@@ -389,7 +390,7 @@
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
|
||||
:disabled="binary.busy || !binary.legacyfile"
|
||||
@click="uploadLegacy(binary.key)"
|
||||
>
|
||||
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
|
||||
@@ -756,7 +757,8 @@ async function detectVersion(binkey, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await api.post(
|
||||
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
|
||||
`/endpoints/siteagent/binaries/${binkey}/detect`, form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
return response.data.data?.version || ''
|
||||
}
|
||||
|
||||
@@ -789,7 +791,7 @@ function onLegacyFileChange(binkey, event) {
|
||||
|
||||
async function uploadBinary(binkey) {
|
||||
const binary = binaryFor(binkey)
|
||||
if (!binary || !binary.file || !binary.version) return
|
||||
if (!binary || !binary.file) return
|
||||
binary.busy = true
|
||||
binary.message = ''
|
||||
binary.error = ''
|
||||
@@ -798,7 +800,8 @@ async function uploadBinary(binkey) {
|
||||
form.append('version', binary.version)
|
||||
form.append('file', binary.file)
|
||||
const response = await api.post(
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form)
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
const data = response.data.data || {}
|
||||
applyBinaryStatus(binkey, data.current, data.versions)
|
||||
binary.version = ''
|
||||
@@ -817,7 +820,7 @@ async function uploadBinary(binkey) {
|
||||
|
||||
async function uploadLegacy(binkey) {
|
||||
const binary = binaryFor(binkey)
|
||||
if (!binary || !binary.legacyfile || !binary.legacyversion) return
|
||||
if (!binary || !binary.legacyfile) return
|
||||
binary.busy = true
|
||||
binary.message = ''
|
||||
binary.error = ''
|
||||
@@ -827,7 +830,8 @@ async function uploadLegacy(binkey) {
|
||||
form.append('file', binary.legacyfile)
|
||||
form.append('makecurrent', 'false')
|
||||
const response = await api.post(
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form)
|
||||
`/endpoints/siteagent/binaries/${binkey}`, form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
const data = response.data.data || {}
|
||||
applyBinaryStatus(binkey, data.current, data.versions)
|
||||
binary.legacyversion = ''
|
||||
@@ -906,10 +910,18 @@ function editConfiguration() {
|
||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||
.info-list li { margin-bottom: 0.5rem; }
|
||||
/* Each agent is its own nested card inside the collapsible, so the two
|
||||
binaries read as distinct units instead of one continuous wall of
|
||||
controls separated by a hairline rule. */
|
||||
.binary-block {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.binary-block:first-of-type { margin-top: 0.75rem; }
|
||||
.binary-title {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
.binary-upload {
|
||||
display: flex;
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<th>Machine</th>
|
||||
<th>PC Type</th>
|
||||
<th>Health</th>
|
||||
<th>Agent Version</th>
|
||||
<th>Logged-in User</th>
|
||||
<th>Uptime</th>
|
||||
<th>Last Reported</th>
|
||||
@@ -123,12 +124,13 @@
|
||||
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ item.agentversion || '-' }}</td>
|
||||
<td>{{ item.loggedinuser || '-' }}</td>
|
||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td :colspan="cancommand ? 9 : 8" style="text-align: center; color: var(--text-light);">
|
||||
<td :colspan="cancommand ? 10 : 9" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Dedup curated Windows events on the Windows event record id.
|
||||
|
||||
Adds endpointevents.eventrecordid and a unique index over
|
||||
(assetid, eventchannel, eventrecordid).
|
||||
|
||||
Why: the agent used to re-read the newest window of every curated channel on
|
||||
each cadence tick, and this ingest appended those rows without a dedup check, so
|
||||
one real event was stored once per report for as long as it stayed in that
|
||||
window - up to 96 identical rows a day. The agent now carries a per-channel
|
||||
watermark keyed on the Windows record id, which is monotonic and unique within a
|
||||
channel on a machine; this index is the server-side guarantee that survives an
|
||||
agent reinstall, a restored image, or two overlapping reports, none of which
|
||||
preserve the agent's watermark.
|
||||
|
||||
Nullable on purpose: an agent older than this field reports no record id, and
|
||||
MySQL permits repeated NULLs inside a unique index, so those rows keep the
|
||||
previous append-only behavior instead of failing ingest.
|
||||
|
||||
Existing rows are left alone. They all predate the column and so carry NULL,
|
||||
which cannot collide with the new index. Purging the historical duplicates is a
|
||||
separate, deliberate data cleanup - not something a schema migration should do
|
||||
irreversibly.
|
||||
|
||||
Idempotent: both the column-add and the index-create check the live schema
|
||||
first, so a db.create_all() site and a re-run both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0012eventrecordid'
|
||||
down_revision = 'endpoints0011trayversion'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEX_NAME = 'idx_endpointevents_assetrecord'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _has_index(table_name, index_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(index['name'] == index_name
|
||||
for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column('endpointevents', 'eventrecordid'):
|
||||
op.add_column(
|
||||
'endpointevents',
|
||||
sa.Column('eventrecordid', sa.BigInteger(), nullable=True))
|
||||
|
||||
if not _has_index('endpointevents', INDEX_NAME):
|
||||
op.create_index(
|
||||
INDEX_NAME,
|
||||
'endpointevents',
|
||||
['assetid', 'eventchannel', 'eventrecordid'],
|
||||
unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_index('endpointevents', INDEX_NAME):
|
||||
op.drop_index(INDEX_NAME, table_name='endpointevents')
|
||||
|
||||
if _has_column('endpointevents', 'eventrecordid'):
|
||||
op.drop_column('endpointevents', 'eventrecordid')
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Persist the hardware, OS, and session inventory the agent already reports.
|
||||
|
||||
Adds to endpointstates: disksjson, biosjson, memoryjson, processorjson,
|
||||
osinfojson, usercontextjson, domain, and pendingreboot.
|
||||
|
||||
Why now: the agent has been sending disks, bios, memory, processor, osinfo and
|
||||
domain on every report for several versions. The ingest logged them as unknown
|
||||
payload keys and threw them away, which was the intended staging step - the
|
||||
shapes are now proven against real endpoint traffic, so they get columns.
|
||||
usercontext and pendingreboot are new in this same change.
|
||||
|
||||
Shape follows the existing servicesjson/portsjson convention: newest snapshot
|
||||
per endpoint as MEDIUMTEXT, not one row per sample. The agent resends the full
|
||||
structure every cycle and operators ask "what is in this PC now", not "what was
|
||||
in it in March". History would be pure write amplification on a fleet-sized
|
||||
table.
|
||||
|
||||
domain and pendingreboot are real columns rather than JSON because they are
|
||||
single values the fleet view filters, groups, and alerts on; burying a scalar in
|
||||
a blob makes it unqueryable.
|
||||
|
||||
All columns are nullable. Every existing row predates them, and there is no
|
||||
correct backfill value - a machine whose reboot state has never been reported is
|
||||
NOT the same as one confirmed to have no reboot pending, and inventing False
|
||||
would silently create a fleet of "healthy" endpoints.
|
||||
|
||||
Idempotent: each column-add checks the live schema first, so a db.create_all()
|
||||
site and a re-run both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0013inventorycolumns'
|
||||
down_revision = 'endpoints0012eventrecordid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
# MEDIUMTEXT. An installed-application or disk list on a busy engineering
|
||||
# workstation comfortably exceeds the 64KB TEXT ceiling, and a silent truncation
|
||||
# there would corrupt the JSON rather than merely shorten it.
|
||||
_SNAPSHOT_COLUMNS = (
|
||||
'disksjson',
|
||||
'biosjson',
|
||||
'memoryjson',
|
||||
'processorjson',
|
||||
'osinfojson',
|
||||
'usercontextjson',
|
||||
)
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
for column_name in _SNAPSHOT_COLUMNS:
|
||||
if not _has_column(TABLE_NAME, column_name):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
if not _has_column(TABLE_NAME, 'domain'):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column('domain', sa.String(length=255), nullable=True))
|
||||
|
||||
if not _has_column(TABLE_NAME, 'pendingreboot'):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column('pendingreboot', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, 'pendingreboot'):
|
||||
op.drop_column(TABLE_NAME, 'pendingreboot')
|
||||
|
||||
if _has_column(TABLE_NAME, 'domain'):
|
||||
op.drop_column(TABLE_NAME, 'domain')
|
||||
|
||||
for column_name in reversed(_SNAPSHOT_COLUMNS):
|
||||
if _has_column(TABLE_NAME, column_name):
|
||||
op.drop_column(TABLE_NAME, column_name)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Add diskhealthjson to endpointstates.
|
||||
|
||||
Per-drive health, kept separate from disksjson. That column answers "how full
|
||||
is this drive"; this one answers "is this drive dying". Merging them would mean
|
||||
a capacity collection failure and a health collection failure could not be told
|
||||
apart, and the two come from different WMI sources with different failure modes.
|
||||
|
||||
JSON rather than scalar columns because a machine has a variable number of
|
||||
drives and each carries its own verdict, and because the agent reports the same
|
||||
drive from two sources - MSFT_PhysicalDisk for NVMe and Win32_DiskDrive for
|
||||
ATA/SATA - which the server keeps side by side rather than collapsing.
|
||||
|
||||
MEDIUMTEXT for consistency with the other snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. A drive whose health has never been reported is not
|
||||
a healthy drive, and defaulting to an empty list would read as "no disks have
|
||||
problems" on every endpoint that predates this column.
|
||||
|
||||
Idempotent: the column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0014diskhealth'
|
||||
down_revision = 'endpoints0013inventorycolumns'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
COLUMN_NAME = 'diskhealthjson'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.drop_column(TABLE_NAME, COLUMN_NAME)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates.
|
||||
|
||||
Phase 2 system-side telemetry. All three are collect-and-persist; none is
|
||||
surfaced in the UI by this change.
|
||||
|
||||
rebootreasonsjson is NOT redundant with the existing pendingreboot column. That
|
||||
column is a single boolean OR-ed from three registry signals, so it cannot say
|
||||
which one fired, and the difference is operationally significant: a
|
||||
PendingFileRenameOperations entry means files are staged for replacement on the
|
||||
next boot, so the machine is mid-servicing, whereas a lone Component Based
|
||||
Servicing or Windows Update key can sit set for weeks on a box nobody has
|
||||
rebooted. Same boolean, very different urgency.
|
||||
|
||||
bootinfojson exists because uptime lies under fast startup - a machine 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 a
|
||||
support call is usually trying to clear.
|
||||
|
||||
defenderjson is stored deliberately unsurfaced. Defender is known to produce
|
||||
false positives in this environment, so wiring it to a card now would train
|
||||
operators to ignore that card; persisting it starts the history accumulating for
|
||||
whenever someone is ready to interpret it.
|
||||
|
||||
JSON rather than scalars: reboot reasons is a variable-length list, and the boot
|
||||
and Defender payloads are small related groups that are read together and would
|
||||
otherwise be six sparse columns.
|
||||
|
||||
MEDIUMTEXT for consistency with the other snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. Null means never reported, which is deliberately
|
||||
distinct from an empty list (checked, nothing pending) - defaulting the reboot
|
||||
reasons to [] would assert every pre-existing endpoint was confirmed clean.
|
||||
|
||||
Idempotent: each column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0015systemtelemetry'
|
||||
down_revision = 'endpoints0014diskhealth'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
_COLUMNS = (
|
||||
'rebootreasonsjson',
|
||||
'bootinfojson',
|
||||
'defenderjson',
|
||||
)
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
for column_name in _COLUMNS:
|
||||
if not _has_column(TABLE_NAME, column_name):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
for column_name in reversed(_COLUMNS):
|
||||
if _has_column(TABLE_NAME, column_name):
|
||||
op.drop_column(TABLE_NAME, column_name)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Add dellupdatesjson to endpointstates.
|
||||
|
||||
Stores the pending Dell Command | Update summary reported by the
|
||||
shopdb-dellupdates agent utility. MEDIUMTEXT for consistency with the other
|
||||
snapshot columns on this table.
|
||||
|
||||
Nullable with no backfill. Null means the endpoint has never reported a Dell
|
||||
scan result, which is deliberately distinct from a stored payload whose status
|
||||
is 'ok' with an empty list (scanned, nothing pending). The status card renders
|
||||
those two states differently, so defaulting this to an empty summary would
|
||||
assert every pre-existing endpoint had been scanned and found clean.
|
||||
|
||||
Idempotent: the column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0016dellupdates'
|
||||
down_revision = 'endpoints0015systemtelemetry'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
|
||||
COLUMN_NAME = 'dellupdatesjson'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.drop_column(TABLE_NAME, COLUMN_NAME)
|
||||
@@ -80,6 +80,65 @@ class EndpointState(db.Model):
|
||||
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Hardware and OS inventory, same newest-snapshot rule as above. These
|
||||
# arrived on the wire well before this and were logged as unknown keys, so
|
||||
# the shapes are already proven against real endpoint traffic.
|
||||
disksjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
biosjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
memoryjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
processorjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
osinfojson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Session-scoped data collected by a probe running as the signed-in user
|
||||
# (mapped drives, user-owned listeners). Null when nobody is signed in,
|
||||
# which is a normal unattended state rather than a fault.
|
||||
usercontextjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Windows domain or workgroup membership. A scalar, not JSON: it is a single
|
||||
# value the fleet view filters and groups on.
|
||||
domain = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# True when the endpoint is holding a reboot. Null = never reported, which
|
||||
# is deliberately distinct from a confident False.
|
||||
pendingreboot = db.Column(db.Boolean, nullable=True)
|
||||
|
||||
# Per-drive health from the agent's two disk sources. JSON because a machine
|
||||
# has a variable number of drives and each carries its own verdict. Kept
|
||||
# separate from disksjson: that is capacity, this is whether the hardware is
|
||||
# dying. Null = never reported.
|
||||
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Which signal(s) drove pendingreboot. That column is a single bool OR-ed
|
||||
# from three sources and so cannot say WHY, and the difference matters: a
|
||||
# pendingfilerename means files are staged for replacement on next boot, so
|
||||
# the box is mid-servicing, while a lone servicing key can sit set for weeks
|
||||
# on a machine nobody rebooted. Empty list = checked, nothing pending.
|
||||
# Null = never reported or the check failed.
|
||||
rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Fast startup configuration and what the last boot actually did. Kept
|
||||
# because uptime is not trustworthy under fast startup: a box can report
|
||||
# days of uptime after what the operator experienced as a shutdown, and a
|
||||
# "reboot" that was a hybrid resume does not clear the state a support call
|
||||
# is usually trying to clear.
|
||||
bootinfojson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Defender health. Persisted but deliberately NOT surfaced anywhere yet:
|
||||
# this environment produces known false positives, and wiring it to a card
|
||||
# now would train operators to ignore that card. Stored so the history is
|
||||
# already accumulating when someone is ready to interpret it.
|
||||
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Pending Dell Command | Update items, produced by the shopdb-dellupdates
|
||||
# agent utility from the log the \ShopDB-DellCommandScan task writes. Only
|
||||
# the count drives the status card; the short list is stored so the panel
|
||||
# can show what is actually pending without a second round trip.
|
||||
#
|
||||
# A null column and a stored payload with status 'unknown' both mean "never
|
||||
# scanned", which stays distinct from a scan that found nothing pending. The
|
||||
# status card relies on that distinction - see _dell_updates_field.
|
||||
dellupdatesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||
# the endpoint is expected to be running but the latest snapshot does not
|
||||
@@ -116,6 +175,8 @@ class EndpointState(db.Model):
|
||||
'idleseconds': self.idleseconds,
|
||||
'cpupercent': self.cpupercent,
|
||||
'memorypercent': self.memorypercent,
|
||||
'domain': self.domain,
|
||||
'pendingreboot': self.pendingreboot,
|
||||
'mtconnectrunning': self.mtconnectrunning,
|
||||
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
|
||||
'lastreportedat': _utciso(self.lastreportedat),
|
||||
@@ -255,6 +316,16 @@ class EndpointEvent(db.Model):
|
||||
eventchannel = db.Column(db.String(50), nullable=True)
|
||||
eventsource = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# The Windows event record id: monotonic and unique within its channel on a
|
||||
# given machine. This is the dedup key. The agent also uses it as its
|
||||
# harvest watermark, so in normal operation a record is sent once; the
|
||||
# unique index below is what guarantees it even when an agent is reinstalled
|
||||
# (losing its watermark) or two reports overlap. Nullable because an agent
|
||||
# older than this field still reports, and MySQL permits repeated NULLs in a
|
||||
# unique index, so those degrade to the previous append-only behavior rather
|
||||
# than failing.
|
||||
eventrecordid = db.Column(db.BigInteger, nullable=True)
|
||||
|
||||
# When the event happened on the endpoint (from the record), not when it
|
||||
# reached ShopDB.
|
||||
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
||||
@@ -269,6 +340,8 @@ class EndpointEvent(db.Model):
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
||||
db.Index('idx_endpointevents_assetrecord',
|
||||
'assetid', 'eventchannel', 'eventrecordid', unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -279,6 +352,7 @@ class EndpointEvent(db.Model):
|
||||
'endpointeventid': self.endpointeventid,
|
||||
'assetid': self.assetid,
|
||||
'eventid': self.eventid,
|
||||
'eventrecordid': self.eventrecordid,
|
||||
'eventchannel': self.eventchannel,
|
||||
'eventsource': self.eventsource,
|
||||
'eventtime': _utciso(self.eventtime),
|
||||
|
||||
@@ -52,6 +52,9 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
||||
'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
||||
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
||||
'scheduledtasks', 'applications', 'events',
|
||||
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
||||
'pendingreboot', 'usercontext', 'diskhealth',
|
||||
'rebootreasons', 'bootinfo', 'defender', 'dellupdates',
|
||||
})
|
||||
|
||||
|
||||
@@ -179,12 +182,32 @@ class EndpointsPlugin(BasePlugin):
|
||||
# remote reboot is a strictly heavier grant than a routine toggle.
|
||||
('endpoints.restart',
|
||||
'Restart endpoint computers (destructive)', 'endpoints'),
|
||||
# Elevated grant for software delivery (run_script, update_app).
|
||||
# Elevated grant for software delivery (run_script, update_app) and
|
||||
# for starting approved scheduled tasks (scheduledtask_run).
|
||||
# Required IN ADDITION to endpoints.command by the enqueue route, so
|
||||
# running a staged script or pushing an installer is a strictly
|
||||
# heavier grant than a routine service/process toggle.
|
||||
#
|
||||
# NOTE FOR THE ROLES WORK - this wire is pre-built for you.
|
||||
# scheduledtask_run was deliberately mapped onto this EXISTING grant
|
||||
# rather than minting a new permission, so nothing needs migrating.
|
||||
# Be aware that WIDENS what endpoints.deploy means: it used to cover
|
||||
# run_script + update_app, and now also covers starting any task on
|
||||
# the agent's approved-task allowlist. Splitting it out is a one-line
|
||||
# change (add endpoints.corptools here and remap it in
|
||||
# commands.ELEVATED_ACTION_PERMISSIONS) - the enqueue route reads
|
||||
# that table, so no other code changes.
|
||||
#
|
||||
# If you do split it, note the three are NOT equal risk:
|
||||
# scheduledtask_run only starts something already registered on the
|
||||
# endpoint and is allowlisted agent-side; update_app runs a pinned
|
||||
# installer; run_script is arbitrary code that runs as SYSTEM unless
|
||||
# its descriptor pins the user context. run_script is the reason this
|
||||
# grant's description has to stay blunt.
|
||||
('endpoints.deploy',
|
||||
'Run staged scripts and push application installers to endpoints', 'endpoints'),
|
||||
'Run staged scripts, start approved scheduled tasks, and push '
|
||||
'application installers to endpoints (scripts run as SYSTEM; '
|
||||
'grant only to fully trusted operators)', 'endpoints'),
|
||||
# Packaging the agent is a heavier grant than deploying to one, and
|
||||
# it is not the same job either: the generated ZIP carries a live
|
||||
# collector key stamped into its install script, so whoever holds
|
||||
@@ -391,7 +414,9 @@ class EndpointsPlugin(BasePlugin):
|
||||
'position': 12,
|
||||
# Service state is what is being watched; the Start/Stop
|
||||
# controls belong beside it, not in a tab of their own.
|
||||
'group': 'monitors',
|
||||
# Lives in serviceprocess rather than monitors: that tab is the
|
||||
# box's own health, this is what runs on it.
|
||||
'group': 'serviceprocess',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-tracked-processes',
|
||||
@@ -431,7 +456,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'empty': 'No monitored processes reported.',
|
||||
'position': 13,
|
||||
# observed process state, not something you act on here
|
||||
'group': 'monitors',
|
||||
'group': 'serviceprocess',
|
||||
},
|
||||
{
|
||||
# One feed instead of two cards. Events and commands answer the
|
||||
@@ -472,6 +497,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
{'key': 'detail'},
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||
],
|
||||
# Expands in place on click. The message body is several
|
||||
# hundred words of Windows boilerplate around the one or
|
||||
# two lines that matter, so it stays collapsed until asked
|
||||
# for rather than pushing every other row off the card.
|
||||
'body': 'body',
|
||||
},
|
||||
'empty': 'Nothing recorded for this endpoint yet.',
|
||||
'position': 20,
|
||||
@@ -493,10 +523,46 @@ class EndpointsPlugin(BasePlugin):
|
||||
{'key': 'subnet', 'label': 'Subnet'},
|
||||
{'key': 'dhcp', 'label': 'Assignment'},
|
||||
{'key': 'role', 'label': 'Role'},
|
||||
{'key': 'link', 'label': 'Link'},
|
||||
],
|
||||
'empty': 'No network interfaces reported.',
|
||||
'position': 16,
|
||||
# adapter state is watched, not something you act on
|
||||
# adapter state is watched, not something you act on.
|
||||
# Declared in monitors but RENDERED at the top of the Details
|
||||
# tab (PCDetail.vue lifts it by id and excludes it here): which
|
||||
# adapter is up and what address it holds is asked at the same
|
||||
# moment as "what is this box".
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# What this box has been on the network, carved out of the
|
||||
# hand-written Network card that used to sit on Details and
|
||||
# show only current addresses with a Primary badge.
|
||||
#
|
||||
# The history is ACCIDENTAL: _record_interfaces matches on MAC
|
||||
# then IP and never deletes, so old addresses accumulate. That
|
||||
# turned out to be worth reading rather than worth cleaning
|
||||
# away, so it is presented as what it already is.
|
||||
'id': 'endpoints-iphistory',
|
||||
'title': 'IP History',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/iphistory',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
|
||||
{'key': 'macaddress', 'label': 'MAC', 'mono': True},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen',
|
||||
'format': 'date'},
|
||||
# NOT "last seen": modifieddate only moves when a field on
|
||||
# the row changes, so an address still held and reported
|
||||
# unchanged keeps an old stamp here.
|
||||
{'key': 'lastchanged', 'label': 'Last changed',
|
||||
'format': 'date'},
|
||||
],
|
||||
'empty': 'No addresses on record for this endpoint.',
|
||||
'position': 16.5,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
@@ -524,10 +590,123 @@ class EndpointsPlugin(BasePlugin):
|
||||
# the installed-software inventory
|
||||
'group': 'software',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-hardware',
|
||||
'title': 'Hardware',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/hardware',
|
||||
'render': 'keyvalue',
|
||||
'collapsible': True,
|
||||
'empty': 'No hardware inventory reported.',
|
||||
'position': 14,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
'id': 'endpoints-osinfo',
|
||||
'title': 'Operating system',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/osinfo',
|
||||
'render': 'keyvalue',
|
||||
'collapsible': True,
|
||||
'empty': 'No OS inventory reported.',
|
||||
'position': 15,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Session-scoped, so it sits with the other things watched
|
||||
# rather than acted on. The user context card still carries a
|
||||
# one-line summary; this is the same data with room to show a
|
||||
# remote path and a per-drive badge, and both are built from
|
||||
# _mapped_drive_rows so they cannot disagree.
|
||||
#
|
||||
# Room reserved here for the future one-click reconnect: the
|
||||
# list render already supports a row action, so adding it is a
|
||||
# map/commandendpoint change rather than a new card.
|
||||
'id': 'endpoints-mappeddrives',
|
||||
'title': 'Mapped drives',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/mappeddrives',
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
'green': 'ok',
|
||||
'yellow': 'warning',
|
||||
'red': 'critical',
|
||||
},
|
||||
},
|
||||
'map': {
|
||||
'title': 'drive',
|
||||
'meta': [{'key': 'remote', 'mono': True}],
|
||||
'badge': {'label': 'statuslabel', 'color': 'statecolor'},
|
||||
},
|
||||
'empty': 'No mapped drives reported.',
|
||||
'position': 21,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Volumes and physical drive health in one card, as two
|
||||
# sections. VISUAL GROUPING ONLY: the two tables share no key
|
||||
# (storage is per volume, health is per physical device) and
|
||||
# nothing is reconciled across them. They are one card because
|
||||
# the operator reading a full disk and the one reading a dying
|
||||
# disk are usually the same person in the same minute.
|
||||
'id': 'endpoints-storage',
|
||||
'title': 'Storage',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/storage',
|
||||
'render': 'tabs',
|
||||
'collapsible': True,
|
||||
'empty': 'No disks reported.',
|
||||
'position': 17,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# The listening set is the box's exposed network surface. The
|
||||
# full connection table is deliberately not here: it is an
|
||||
# on-demand command, because a per-cycle capture of every
|
||||
# connection is a lot of rows nobody reads.
|
||||
'id': 'endpoints-listeners',
|
||||
'title': 'Listening ports',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/listeners',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'localport', 'label': 'Port'},
|
||||
{'key': 'protocol', 'label': 'Protocol'},
|
||||
{'key': 'localaddress', 'label': 'Address'},
|
||||
{'key': 'processname', 'label': 'Process'},
|
||||
{'key': 'processid', 'label': 'PID'},
|
||||
],
|
||||
'empty': 'No listening ports reported.',
|
||||
'position': 19,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Session-scoped data collected by a probe in the interactive
|
||||
# user's session, so it is absent whenever nobody is signed in.
|
||||
# Last on the page: it is the least reliably present card, and
|
||||
# an empty one at the bottom reads as "not reported" rather
|
||||
# than pushing the populated cards down.
|
||||
'id': 'endpoints-usercontext',
|
||||
'title': 'User context',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/usercontext',
|
||||
'render': 'keyvalue',
|
||||
'collapsible': True,
|
||||
'empty': 'No user session data reported.',
|
||||
'position': 40,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Operator-submitted issue reports: a free-text note and an
|
||||
# optional screenshot the operator captured from the tray. Sits
|
||||
# in the Monitors tab beside the other watch-and-observe panels.
|
||||
# optional screenshot the operator captured from the tray. Has
|
||||
# a tab of its own rather than sitting in System Monitors: that
|
||||
# tab is machine-collected health, this is a human telling you
|
||||
# something, and the thumbnail grid is far bulkier than the
|
||||
# fact cards it used to sit between.
|
||||
# A custom render carries the thumbnail grid and the expanding
|
||||
# lightbox with left/right cycling the generic modes cannot.
|
||||
'id': 'endpoints-operator-reports',
|
||||
@@ -539,7 +718,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'collapsible': True,
|
||||
'empty': 'No operator reports for this endpoint.',
|
||||
'position': 18,
|
||||
'group': 'monitors',
|
||||
'group': 'reports',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -636,6 +815,80 @@ class EndpointsPlugin(BasePlugin):
|
||||
'type': 'array',
|
||||
'description': 'Scheduled task inventory + last run.',
|
||||
},
|
||||
'disks': {
|
||||
'type': 'array',
|
||||
'description': 'Fixed local disks: {drive, label, '
|
||||
'filesystem, sizebytes, freebytes, '
|
||||
'usedbytes, freepercent}. Newest '
|
||||
'snapshot, replaced on every report.',
|
||||
},
|
||||
'bios': {
|
||||
'type': 'object',
|
||||
'description': 'Firmware identity: vendor, version, '
|
||||
'release date.',
|
||||
},
|
||||
'memory': {
|
||||
'type': 'object',
|
||||
'description': 'Physical memory totals in bytes.',
|
||||
},
|
||||
'processor': {
|
||||
'type': 'object',
|
||||
'description': 'CPU identity, core and logical '
|
||||
'processor counts, max clock.',
|
||||
},
|
||||
'osinfo': {
|
||||
'type': 'object',
|
||||
'description': 'OS build, version, and install date.',
|
||||
},
|
||||
'domain': {
|
||||
'type': 'string',
|
||||
'description': 'Windows domain or workgroup membership.',
|
||||
},
|
||||
'pendingreboot': {
|
||||
'type': 'boolean',
|
||||
'description': 'True when the endpoint is holding a '
|
||||
'reboot. Absent means never reported, '
|
||||
'which is not the same as false.',
|
||||
},
|
||||
'diskhealth': {
|
||||
'type': 'array',
|
||||
'description': 'Per-drive health from two sources: '
|
||||
'physicaldisk (the only one reporting '
|
||||
'NVMe) and diskdrive (ATA/SATA and '
|
||||
'fallback). Each entry carries a '
|
||||
'precomputed predictfailure; null there '
|
||||
'means the drive did not answer.',
|
||||
},
|
||||
'rebootreasons': {
|
||||
'type': 'array',
|
||||
'description': 'Which signal(s) drove pendingreboot: '
|
||||
'componentbasedservicing, windowsupdate, '
|
||||
'pendingfilerename. Empty array means '
|
||||
'checked and nothing pending; omit when '
|
||||
'the check failed.',
|
||||
},
|
||||
'bootinfo': {
|
||||
'type': 'object',
|
||||
'description': 'hiberbootenabled (fast startup is '
|
||||
'configured) and lastboottype (what the '
|
||||
'last boot actually did). Uptime is not '
|
||||
'trustworthy when fast startup is in '
|
||||
'play.',
|
||||
},
|
||||
'defender': {
|
||||
'type': 'object',
|
||||
'description': 'Defender service/realtime state and '
|
||||
'signature and scan ages in days. '
|
||||
'Persisted but not surfaced.',
|
||||
},
|
||||
'usercontext': {
|
||||
'type': 'object',
|
||||
'description': 'Session-scoped data collected by a probe '
|
||||
'running as the signed-in user: mapped '
|
||||
'drives (netuse), user-owned listeners, '
|
||||
'and idle seconds. Absent when nobody is '
|
||||
'signed in.',
|
||||
},
|
||||
'applications': {
|
||||
'type': 'array',
|
||||
'description': 'Installed-software SBOM from both '
|
||||
@@ -663,6 +916,12 @@ class EndpointsPlugin(BasePlugin):
|
||||
'required': ['eventid'],
|
||||
'properties': {
|
||||
'eventid': {'type': 'integer'},
|
||||
'eventrecordid': {
|
||||
'type': 'integer',
|
||||
'description': 'Windows event record id; '
|
||||
'unique per channel per '
|
||||
'machine, used to dedup.',
|
||||
},
|
||||
'eventchannel': {'type': 'string'},
|
||||
'eventsource': {'type': 'string'},
|
||||
'eventtime': {'type': 'string',
|
||||
@@ -846,6 +1105,51 @@ class EndpointsPlugin(BasePlugin):
|
||||
if 'scheduledtasks' in payload:
|
||||
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
||||
|
||||
# Hardware/OS inventory. Same presence guard as above: a report that
|
||||
# omits a key must not wipe the last-known value. These change rarely
|
||||
# (a RAM upgrade, a BIOS flash), so a transient collection failure on
|
||||
# one cycle should leave the previous snapshot standing rather than
|
||||
# blank the asset record.
|
||||
if 'disks' in payload:
|
||||
state.disksjson = _as_json(payload.get('disks'))
|
||||
if 'bios' in payload:
|
||||
state.biosjson = _as_json(payload.get('bios'))
|
||||
if 'memory' in payload:
|
||||
state.memoryjson = _as_json(payload.get('memory'))
|
||||
if 'processor' in payload:
|
||||
state.processorjson = _as_json(payload.get('processor'))
|
||||
if 'osinfo' in payload:
|
||||
state.osinfojson = _as_json(payload.get('osinfo'))
|
||||
if 'domain' in payload:
|
||||
# Clamped like every other String column here: an over-long value
|
||||
# aborts the whole collector transaction under STRICT_TRANS_TABLES,
|
||||
# so one bad agent report would drop a machine's entire telemetry.
|
||||
state.domain = _clamp(payload.get('domain'), 255)
|
||||
if 'pendingreboot' in payload:
|
||||
state.pendingreboot = payload.get('pendingreboot')
|
||||
if 'diskhealth' in payload:
|
||||
state.diskhealthjson = _as_json(payload.get('diskhealth'))
|
||||
if 'rebootreasons' in payload:
|
||||
state.rebootreasonsjson = _as_json(payload.get('rebootreasons'))
|
||||
if 'bootinfo' in payload:
|
||||
state.bootinfojson = _as_json(payload.get('bootinfo'))
|
||||
if 'defender' in payload:
|
||||
state.defenderjson = _as_json(payload.get('defender'))
|
||||
|
||||
# Presence-guarded like the probe output below: the agent omits the key
|
||||
# entirely when the collector utility is not installed, and overwriting
|
||||
# to null there would discard a still-valid scan result from before the
|
||||
# utility was removed.
|
||||
if 'dellupdates' in payload:
|
||||
state.dellupdatesjson = _as_json(payload.get('dellupdates'))
|
||||
|
||||
# Session-scoped probe output. The agent repeats its last result between
|
||||
# probe runs and omits the key entirely when nobody is signed in, so the
|
||||
# presence guard preserves the last known mapped drives across an
|
||||
# unattended period instead of flapping the field to null.
|
||||
if 'usercontext' in payload:
|
||||
state.usercontextjson = _as_json(payload.get('usercontext'))
|
||||
|
||||
# Installed-software SBOM: stored as a per-asset SNAPSHOT in its own
|
||||
# table (endpointapplications), not a JSON blob on the state, so it can
|
||||
# later join the applications catalog ("Managed Applications"). Only
|
||||
@@ -897,6 +1201,19 @@ class EndpointsPlugin(BasePlugin):
|
||||
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
|
||||
|
||||
stored_events = 0
|
||||
skipped_events = 0
|
||||
|
||||
# Records the agent already sent must not be appended a second time.
|
||||
# The agent carries a per-channel watermark so the steady state is
|
||||
# "only what is new", but a reinstalled agent, a restored image, or two
|
||||
# overlapping reports can all replay records. Resolve that here rather
|
||||
# than relying on the unique index to raise, because an IntegrityError
|
||||
# would abort the whole collector transaction and lose the state upsert
|
||||
# along with it.
|
||||
#
|
||||
# One query for the whole batch, not one per record: a seeding agent
|
||||
# sends up to the per-channel cap at once.
|
||||
candidates = []
|
||||
for record in (payload.get('events') or []):
|
||||
try:
|
||||
eventid = int(record.get('eventid'))
|
||||
@@ -906,10 +1223,45 @@ class EndpointsPlugin(BasePlugin):
|
||||
if eventid not in CURATED_EVENT_IDS:
|
||||
# Outside the catalog: ignored on purpose, not an error.
|
||||
continue
|
||||
|
||||
try:
|
||||
eventrecordid = record.get('eventrecordid')
|
||||
eventrecordid = (int(eventrecordid)
|
||||
if eventrecordid is not None else None)
|
||||
except (TypeError, ValueError):
|
||||
eventrecordid = None
|
||||
|
||||
candidates.append((eventid, eventrecordid, record))
|
||||
|
||||
keyed = [(record.get('eventchannel'), eventrecordid)
|
||||
for _e, eventrecordid, record in candidates
|
||||
if eventrecordid is not None]
|
||||
already_stored = set()
|
||||
if keyed:
|
||||
rows = (db.session.query(EndpointEvent.eventchannel,
|
||||
EndpointEvent.eventrecordid)
|
||||
.filter(EndpointEvent.assetid == assetid,
|
||||
EndpointEvent.eventrecordid.in_(
|
||||
[recordid for _c, recordid in keyed]))
|
||||
.all())
|
||||
already_stored = {(channel, recordid) for channel, recordid in rows}
|
||||
|
||||
for eventid, eventrecordid, record in candidates:
|
||||
eventchannel = record.get('eventchannel')
|
||||
if eventrecordid is not None:
|
||||
key = (eventchannel, eventrecordid)
|
||||
if key in already_stored:
|
||||
skipped_events += 1
|
||||
continue
|
||||
# Guard against the same record appearing twice inside one
|
||||
# payload as well as against rows already in the table.
|
||||
already_stored.add(key)
|
||||
|
||||
db.session.add(EndpointEvent(
|
||||
assetid=assetid,
|
||||
eventid=eventid,
|
||||
eventchannel=record.get('eventchannel'),
|
||||
eventrecordid=eventrecordid,
|
||||
eventchannel=eventchannel,
|
||||
eventsource=record.get('eventsource'),
|
||||
eventtime=_parse_dt(record.get('eventtime')),
|
||||
eventmessage=record.get('eventmessage'),
|
||||
@@ -917,6 +1269,11 @@ class EndpointsPlugin(BasePlugin):
|
||||
))
|
||||
stored_events += 1
|
||||
|
||||
if skipped_events:
|
||||
logger.debug(
|
||||
'endpoint %s replayed %d already-stored event record(s)',
|
||||
hostname, skipped_events)
|
||||
|
||||
# flush, not commit: the collector dispatcher owns the transaction.
|
||||
db.session.flush()
|
||||
|
||||
|
||||
@@ -591,14 +591,14 @@ onMounted(async () => {
|
||||
|
||||
machineTypes.value = typesRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes.data.data || []
|
||||
// listAll resolves to the ARRAY, not a response: fetchAllPages already
|
||||
// unwrapped every page. Reading .data.data off it threw, and the whole
|
||||
// parallel load went to the catch - so every dropdown on this form came up
|
||||
// empty and the machine's own values never loaded.
|
||||
vendors.value = vendorRes || []
|
||||
locations.value = locRes || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes || []
|
||||
pcs.value = pcsRes || []
|
||||
|
||||
// Load relationship types separately
|
||||
|
||||
@@ -187,8 +187,11 @@ onMounted(async () => {
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
statuses.value = statusResponse.data.data || []
|
||||
// listAll resolves to the ARRAY, not a response: fetchAllPages already
|
||||
// unwrapped every page. Reading .data.data off it throws and takes the
|
||||
// whole parallel load into the catch.
|
||||
locations.value = locResponse || []
|
||||
businessunits.value = buResponse.data.data || []
|
||||
businessunits.value = buResponse || []
|
||||
|
||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||
assettypeid.value = assetType ? assetType.assettypeid : null
|
||||
|
||||
4
scripts/backup/.gitattributes
vendored
Normal file
4
scripts/backup/.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Keep shell scripts LF-only so they run inside Linux containers regardless of
|
||||
# the host checkout. A CRLF shebang makes the kernel look for "bash\r" and the
|
||||
# container dies with: env: 'bash\r': No such file or directory.
|
||||
*.sh text eol=lf
|
||||
53
scripts/backup/Dockerfile.backup
Normal file
53
scripts/backup/Dockerfile.backup
Normal file
@@ -0,0 +1,53 @@
|
||||
# Backup sidecar image: mysql:8.4 (for a version-matched mysqldump) plus restic
|
||||
# (for deduped, encrypted, destination-agnostic file snapshots) and a small
|
||||
# Python for the manifest writer.
|
||||
#
|
||||
# WHY mysql:8.4 as the base: the dump tool must match the server major/minor so
|
||||
# the SQL it emits restores cleanly onto the same engine. Reusing the exact
|
||||
# image the db service runs removes version drift as a failure mode.
|
||||
FROM mysql:8.4
|
||||
|
||||
# Track 1 local build unblock: trust the corporate proxy (Zscaler/GE) root CA so
|
||||
# the build-time fetches below (Oracle yum, github.com, PyPI) succeed behind TLS
|
||||
# interception. This is a local convenience only; the shipping build vendors
|
||||
# these deps instead of fetching them (pinned restic + SHA256, vendored
|
||||
# bzip2/python3/alembic or a base image that carries them) so the release image
|
||||
# needs no outbound calls and no embedded cert. The cert is gitignored (*.pem)
|
||||
# and must be staged at scripts/backup/zscaler-root-ca.pem before building.
|
||||
#
|
||||
# Copied via the directory rather than by name, because the cert is OPTIONAL: it
|
||||
# is gitignored, so a plain `COPY scripts/backup/zscaler-root-ca.pem` fails the
|
||||
# build outright and nobody outside a TLS-intercepting network - another site,
|
||||
# or our own CI - could build this image at all. A trailing glob does not fix
|
||||
# that either: BuildKit fails a COPY whose pattern matches nothing, which is
|
||||
# precisely the case being handled. Copying a directory that always exists and
|
||||
# testing for the file inside it is the form that works both ways.
|
||||
COPY scripts/backup/ /tmp/buildctx/
|
||||
RUN if [ -f /tmp/buildctx/zscaler-root-ca.pem ]; then \
|
||||
echo "staging corporate root CA"; \
|
||||
cp /tmp/buildctx/zscaler-root-ca.pem /etc/pki/ca-trust/source/anchors/; \
|
||||
update-ca-trust; \
|
||||
else \
|
||||
echo "no corporate root CA staged - using the public trust store"; \
|
||||
fi \
|
||||
&& rm -rf /tmp/buildctx
|
||||
|
||||
# restic from the distro is often stale; pull the pinned release binary so the
|
||||
# snapshot format and retention flags are predictable across sites.
|
||||
ARG RESTIC_VERSION=0.17.3
|
||||
RUN microdnf install -y bzip2 python3 python3-pip ca-certificates \
|
||||
&& curl -fsSL -o /tmp/restic.bz2 \
|
||||
"https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2" \
|
||||
&& bunzip2 /tmp/restic.bz2 \
|
||||
&& install -m 0755 /tmp/restic /usr/local/bin/restic \
|
||||
&& rm -f /tmp/restic \
|
||||
&& microdnf clean all
|
||||
|
||||
# alembic so the manifest writer can read the current schema head. pip does not
|
||||
# read the system trust store, so point it at the extracted system CA bundle
|
||||
# (now including the proxy root) to get through TLS interception.
|
||||
RUN python3 -m pip install --no-cache-dir \
|
||||
--cert /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
|
||||
alembic
|
||||
|
||||
ENTRYPOINT ["/opt/backup/backup.sh"]
|
||||
44
scripts/backup/SIMULATE-RESTORE.README.md
Normal file
44
scripts/backup/SIMULATE-RESTORE.README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Restore Simulation (FOR DEVELOPER USE ONLY)
|
||||
|
||||
`simulate-restore.sh` is a developer diagnostic that proves the backup/restore
|
||||
pair round-trips the live database. It is NOT an operator tool and NOT part of
|
||||
the shipped feature. It mutates a live database on purpose - never run it
|
||||
against production or anything you cannot disturb.
|
||||
|
||||
## Testing cadence
|
||||
|
||||
- Before merging any change to `scripts/backup/*.sh`, `docker-compose.backup.yml`,
|
||||
or `shopdb/core/api/shopdbbackups.py`.
|
||||
- Before cutting a release that touches the backup/restore surface.
|
||||
- After a schema migration that adds or drops tables (new table archetype).
|
||||
- Ad hoc when investigating a restore-fidelity concern.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Requests a backup through the real `.backup-requested` sentinel.
|
||||
2. Fingerprints every base table with `CHECKSUM TABLE` (the restore target).
|
||||
3. Quiesces the `api` service, mutates the table archetypes:
|
||||
- core standalone (vendor rename, setting flip)
|
||||
- consumable ledger (`modelsupplies.quantityonhand` bump)
|
||||
4. Requests a restore through the real `.restore-requested` sentinel.
|
||||
5. Re-fingerprints and asserts every table returned to backup-time state.
|
||||
6. Writes `simulate-restore-results.txt` next to the script.
|
||||
|
||||
## Testing parameters
|
||||
|
||||
- Stack must be up with the backup profile:
|
||||
`docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup up -d`
|
||||
- Run from git bash on the host: `bash scripts/backup/simulate-restore.sh`
|
||||
- Coverage: all `BASE TABLE` rows in `shopdb_flask` (110+ tables), one pass.
|
||||
|
||||
## Expected drift (NOT failures)
|
||||
|
||||
- `apitokens`: the app rewrites token last-used continuously; restore is
|
||||
correct at backup time.
|
||||
- Endpoint-agent tables (`computers`, `endpointstates`, `endpointevents`,
|
||||
`endpointapplications`): external fleet agents keep writing after restore and
|
||||
cannot be quiesced from here.
|
||||
- Asset ROW deletion is not permitted in a live/CMMC environment (RESTRICT +
|
||||
compliance). The cascade edge is reported informationally only. A missing or
|
||||
corrupted asset is still fully restored because the dump drops and recreates
|
||||
the table - restore fidelity does not depend on a live delete.
|
||||
197
scripts/backup/backup.sh
Executable file
197
scripts/backup/backup.sh
Executable file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env bash
|
||||
# ShopDB backup one-shot.
|
||||
#
|
||||
# Produces one recoverable set per run and stores it two ways: a plain
|
||||
# db.sql.gz + manifest.json on the local /backups volume for a fast, repo-free
|
||||
# restore, and a restic snapshot (db dump + instance tree + manifest) at
|
||||
# RESTIC_REPOSITORY for a deduped, encrypted, offsite copy.
|
||||
#
|
||||
# Sequence:
|
||||
# 1. mysqldump --single-transaction (InnoDB-consistent, non-blocking)
|
||||
# 2. manifest.py stamps core+plugin Alembic revisions + image tag
|
||||
# 3. restic backup the dump + /app/instance + manifest as ONE snapshot
|
||||
# 4. restic forget --keep-daily 5 --keep-weekly 4 --keep-monthly 6 --prune
|
||||
# 5. restic check - fail loudly if the repo is unhealthy, so a bad repo is
|
||||
# caught on backup night, not restore night.
|
||||
#
|
||||
# ASCII only, no emojis - matches the repo style gate.
|
||||
set -euo pipefail
|
||||
|
||||
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
|
||||
SITE="${SHOPDB_SITE:-shopdb}"
|
||||
STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
||||
SETNAME="${SITE}-${STAMP}"
|
||||
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
|
||||
|
||||
# shellcheck source=scripts/backup/status.sh
|
||||
. /opt/backup/status.sh
|
||||
|
||||
log() { echo "[backup] $*"; }
|
||||
|
||||
# Record the run outcome on any exit so a failed backup is visible in the UI
|
||||
# history, not just a silent gap.
|
||||
BACKUP_DONE=false
|
||||
_on_exit() {
|
||||
code=$?
|
||||
if [ "${BACKUP_DONE}" = true ]; then
|
||||
status_write backup done "${SETNAME}" "Backup complete"
|
||||
history_append backup success "${SETNAME}" "Backup complete"
|
||||
else
|
||||
status_write backup failed "${SETNAME}" "Backup failed (exit ${code})"
|
||||
history_append backup failed "${SETNAME}" "Backup failed (exit ${code})"
|
||||
fi
|
||||
}
|
||||
trap _on_exit EXIT
|
||||
|
||||
mkdir -p "${SETDIR}"
|
||||
status_write backup database "${SETNAME}" "Dumping database"
|
||||
|
||||
# 1. Database: whole-DB logical dump, consistent and non-blocking on InnoDB.
|
||||
# Dumped WITHOUT --databases on purpose. That flag bakes CREATE DATABASE and USE
|
||||
# <name> into the file, which pins the dump to the schema name it was taken
|
||||
# from: restoring onto a site whose MYSQL_DATABASE differs loaded the rows into
|
||||
# the dump's schema, left the live one untouched, and reported success.
|
||||
# --no-tablespaces because dumping tablespace metadata needs the PROCESS
|
||||
# privilege, which the app user does not have on MySQL 8.
|
||||
log "dumping database ${MYSQL_DATABASE} from ${MYSQL_HOST}"
|
||||
mysqldump \
|
||||
--host="${MYSQL_HOST}" \
|
||||
--user="${MYSQL_USER}" \
|
||||
--password="${MYSQL_PASSWORD}" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--no-tablespaces \
|
||||
"${MYSQL_DATABASE}" \
|
||||
| gzip -c > "${SETDIR}/db.sql.gz"
|
||||
log "database dump written ($(du -h "${SETDIR}/db.sql.gz" | cut -f1))"
|
||||
|
||||
# 2. Manifest: the schema stamp that makes a restore a checked operation.
|
||||
# Capture the revision actually applied to the DATABASE (its alembic_version),
|
||||
# not just the code head. A restore must gate on the DATA revision; stamping
|
||||
# only the code head hides an image that has outrun its own database and lets a
|
||||
# behind-schema dump restore under ahead-schema code (1054 Unknown column on
|
||||
# every affected table). Query it straight from the dumped DB.
|
||||
log "reading applied database revision (alembic_version)"
|
||||
DATAREV="$(mysql \
|
||||
--host="${MYSQL_HOST}" \
|
||||
--user="${MYSQL_USER}" \
|
||||
--password="${MYSQL_PASSWORD}" \
|
||||
--skip-column-names --batch \
|
||||
-e "SELECT version_num FROM alembic_version LIMIT 1;" \
|
||||
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
|
||||
if [ -z "${DATAREV}" ]; then
|
||||
# Recorded loudly rather than shrugged off: a set with no data revision
|
||||
# cannot be gated on restore, and the gate treats an empty stamp as "no
|
||||
# evidence" and refuses it. Better a visible warning now than a set that
|
||||
# looks restorable and is not.
|
||||
log "WARN could not read alembic_version - this set will NOT be restorable"
|
||||
history_append backup warning "${SETNAME}" \
|
||||
"Could not read alembic_version; set has no data revision and cannot be restored"
|
||||
fi
|
||||
|
||||
# The same question for every PLUGIN chain. Each plugin owns an
|
||||
# alembic_version_<plugin> table, and those move independently of the core one:
|
||||
# code rolled back to an older plugin revision can sit at an identical core
|
||||
# head while the dump carries plugin tables it has never heard of. Stamping only
|
||||
# the core revision let that combination pass the restore gate, load, and then
|
||||
# fail during the plugin upgrade with the data already replaced. Read straight
|
||||
# from the dumped DB, same as the core revision above.
|
||||
log "reading applied plugin revisions (alembic_version_*)"
|
||||
DATAPLUGINS="$(mysql \
|
||||
--host="${MYSQL_HOST}" \
|
||||
--user="${MYSQL_USER}" \
|
||||
--password="${MYSQL_PASSWORD}" \
|
||||
--skip-column-names --batch \
|
||||
-e "SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = '${MYSQL_DATABASE}'
|
||||
AND table_name LIKE 'alembic\\_version\\_%';" 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| while read -r table; do
|
||||
[ -n "${table}" ] || continue
|
||||
plugin="${table#alembic_version_}"
|
||||
rev="$(mysql \
|
||||
--host="${MYSQL_HOST}" \
|
||||
--user="${MYSQL_USER}" \
|
||||
--password="${MYSQL_PASSWORD}" \
|
||||
--skip-column-names --batch \
|
||||
-e "SELECT version_num FROM \`${table}\` LIMIT 1;" \
|
||||
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
|
||||
[ -n "${rev}" ] && printf '%s\t%s\n' "${plugin}" "${rev}"
|
||||
done \
|
||||
| python3 -c 'import json,sys; print(json.dumps(dict(l.rstrip("\n").split("\t",1) for l in sys.stdin if "\t" in l)))')"
|
||||
[ -n "${DATAPLUGINS}" ] || DATAPLUGINS='{}'
|
||||
log "plugin revisions: ${DATAPLUGINS}"
|
||||
|
||||
log "writing manifest"
|
||||
python3 /opt/backup/manifest.py \
|
||||
--out "${SETDIR}/manifest.json" \
|
||||
--takenat "${STAMP}" \
|
||||
--site "${SITE}" \
|
||||
--imagetag "${SHOPDB_IMAGE_TAG:-source}" \
|
||||
--datarev "${DATAREV}" \
|
||||
--dataplugins "${DATAPLUGINS}" \
|
||||
--migrations /app/migrations \
|
||||
--plugins /app/plugins
|
||||
|
||||
# Consistency guard: if the DATA revision does not match the CODE head, this
|
||||
# backup captures a database the running image has outrun. It is still stored
|
||||
# (losing it would be worse) but flagged loudly so a restore of it is a
|
||||
# deliberate choice, not a surprise.
|
||||
CODEREV="$(python3 -c \
|
||||
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
|
||||
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
||||
if [ -n "${DATAREV}" ] && [ "${DATAREV}" != "${CODEREV}" ]; then
|
||||
log "WARN schema drift: database is at '${DATAREV}' but code head is '${CODEREV}'"
|
||||
log "WARN this DB has not run 'flask db upgrade' to the running image; restore will upgrade it"
|
||||
history_append backup warning "${SETNAME}" "Schema drift: data ${DATAREV} vs code ${CODEREV}"
|
||||
fi
|
||||
|
||||
# 3. restic snapshot: db dump + instance tree + manifest, one point in time.
|
||||
# Skips gracefully when restic/repo are not configured (local-only sites).
|
||||
IS_CHECK_PASSED=false
|
||||
status_write backup snapshot "${SETNAME}" "Snapshotting to backup store"
|
||||
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
|
||||
if ! restic snapshots >/dev/null 2>&1; then
|
||||
log "initializing restic repository at ${RESTIC_REPOSITORY}"
|
||||
restic init
|
||||
fi
|
||||
log "restic backup (db dump + instance tree + manifest)"
|
||||
restic backup \
|
||||
--tag "${SITE}" \
|
||||
--tag "${SETNAME}" \
|
||||
"${SETDIR}/db.sql.gz" \
|
||||
"${SETDIR}/manifest.json" \
|
||||
/app/instance
|
||||
|
||||
# 4. Retention: any day in the last 5, plus weekly/monthly tail.
|
||||
log "pruning per retention policy"
|
||||
restic forget \
|
||||
--tag "${SITE}" \
|
||||
--keep-daily 5 \
|
||||
--keep-weekly 4 \
|
||||
--keep-monthly 6 \
|
||||
--prune
|
||||
|
||||
# 5. Integrity: learn about corruption now, not at restore time.
|
||||
log "verifying repository integrity"
|
||||
if restic check; then
|
||||
IS_CHECK_PASSED=true
|
||||
log "restic check passed"
|
||||
else
|
||||
log "restic check FAILED - repository may be corrupt"
|
||||
fi
|
||||
else
|
||||
log "restic not configured - local db.sql.gz + manifest only"
|
||||
fi
|
||||
|
||||
# Record the verification result into the manifest so the UI can badge the set.
|
||||
python3 /opt/backup/manifest.py \
|
||||
--amend "${SETDIR}/manifest.json" \
|
||||
--ischeckpassed "${IS_CHECK_PASSED}"
|
||||
|
||||
# Clear the request sentinel the UI drops when an operator clicks "Back up now".
|
||||
rm -f "${BACKUPS_ROOT}/.backup-requested" 2>/dev/null || true
|
||||
|
||||
BACKUP_DONE=true
|
||||
log "backup set ${SETNAME} complete"
|
||||
163
scripts/backup/manifest.py
Normal file
163
scripts/backup/manifest.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backup manifest writer.
|
||||
|
||||
The manifest is the stamp that turns "we have dumps" into "we can actually
|
||||
restore." It records, at capture time:
|
||||
- takenat : ISO-8601 UTC timestamp of the run
|
||||
- site : the site tag
|
||||
- imagetag : the app image tag that produced the data
|
||||
- schemarev : the core Alembic head revision(s) the running CODE is at, plus
|
||||
each plugin chain's heads - the versions a restore target must
|
||||
be at least, or restored rows reference columns the live schema
|
||||
has not applied yet.
|
||||
- datarev : the CORE Alembic revision actually applied to the DATABASE that
|
||||
was dumped (its alembic_version), which is the number a restore
|
||||
must gate on. schemarev stamps the code on disk; datarev stamps
|
||||
the data in the dump. They diverge when an image is ahead of a DB
|
||||
that has not run 'flask db upgrade' yet, and that divergence is
|
||||
exactly what a code-to-code check cannot see.
|
||||
- dataplugins: the same thing for every PLUGIN chain - the applied revision of
|
||||
each alembic_version_<plugin> table. The core chain alone cannot
|
||||
answer compatibility: plugin schemas move independently, so code
|
||||
rolled back to an older plugin revision can sit at an identical
|
||||
core head while the dump carries plugin tables it has never heard
|
||||
of. Gating on core only let that pass, load, and then fail during
|
||||
the plugin upgrade with the data already replaced.
|
||||
|
||||
Two modes:
|
||||
--out ... write a fresh manifest for a new set
|
||||
--amend ... patch an existing manifest (used to record ischeckpassed after
|
||||
restic check runs, or to backfill datarev)
|
||||
|
||||
ASCII only, no emojis - matches the repo style gate.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _heads_for(chain_dir):
|
||||
"""Return the Alembic head revision id(s) under a versions directory.
|
||||
|
||||
We read the revision graph the cheap way: a head is a revision no other
|
||||
revision names as its down_revision. Done by text scan so this stays
|
||||
dependency-light and works for both the core chain and each plugin chain.
|
||||
"""
|
||||
versions = os.path.join(chain_dir, 'versions')
|
||||
if not os.path.isdir(versions):
|
||||
return []
|
||||
revisions = {}
|
||||
down_refs = set()
|
||||
for name in os.listdir(versions):
|
||||
if not name.endswith('.py'):
|
||||
continue
|
||||
path = os.path.join(versions, name)
|
||||
revid = None
|
||||
downid = None
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
for line in handle:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('revision =') and revid is None:
|
||||
revid = stripped.split('=', 1)[1].strip().strip('"\'')
|
||||
elif stripped.startswith('down_revision ='):
|
||||
value = stripped.split('=', 1)[1].strip()
|
||||
downid = None if value in ('None', 'null') else value.strip('"\'')
|
||||
except OSError:
|
||||
continue
|
||||
if revid:
|
||||
revisions[revid] = downid
|
||||
if downid:
|
||||
down_refs.add(downid)
|
||||
return sorted(rev for rev in revisions if rev not in down_refs)
|
||||
|
||||
|
||||
def _parse_dataplugins(raw):
|
||||
"""The applied per-plugin revisions, as a {plugin: revision} map.
|
||||
|
||||
Bad or absent JSON becomes an empty map rather than an error: a manifest
|
||||
that cannot say what the plugin chains were at is exactly the legacy case
|
||||
the restore gate already knows how to treat conservatively, and failing the
|
||||
backup here would lose the set over a stamp.
|
||||
"""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except ValueError:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in value.items() if k and v}
|
||||
|
||||
|
||||
def _collect_schema(migrations_dir, plugins_dir):
|
||||
"""Core heads plus each plugin's chain heads, keyed for compatibility checks."""
|
||||
schema = {'core': _heads_for(migrations_dir), 'plugins': {}}
|
||||
if plugins_dir and os.path.isdir(plugins_dir):
|
||||
for name in sorted(os.listdir(plugins_dir)):
|
||||
chain = os.path.join(plugins_dir, name, 'migrations')
|
||||
heads = _heads_for(chain)
|
||||
if heads:
|
||||
schema['plugins'][name] = heads
|
||||
return schema
|
||||
|
||||
|
||||
def write_manifest(args):
|
||||
manifest = {
|
||||
'takenat': args.takenat,
|
||||
'site': args.site,
|
||||
'imagetag': args.imagetag,
|
||||
'schemarev': _collect_schema(args.migrations, args.plugins),
|
||||
'datarev': args.datarev or '',
|
||||
'dataplugins': _parse_dataplugins(args.dataplugins),
|
||||
'ischeckpassed': False,
|
||||
}
|
||||
with open(args.out, 'w', encoding='utf-8') as handle:
|
||||
json.dump(manifest, handle, indent=2, sort_keys=True)
|
||||
return 0
|
||||
|
||||
|
||||
def amend_manifest(args):
|
||||
try:
|
||||
with open(args.amend, 'r', encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
manifest = {}
|
||||
if args.ischeckpassed is not None:
|
||||
manifest['ischeckpassed'] = args.ischeckpassed.lower() == 'true'
|
||||
if args.datarev is not None:
|
||||
manifest['datarev'] = args.datarev
|
||||
if args.dataplugins is not None:
|
||||
manifest['dataplugins'] = _parse_dataplugins(args.dataplugins)
|
||||
with open(args.amend, 'w', encoding='utf-8') as handle:
|
||||
json.dump(manifest, handle, indent=2, sort_keys=True)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='ShopDB backup manifest writer')
|
||||
parser.add_argument('--out')
|
||||
parser.add_argument('--amend')
|
||||
parser.add_argument('--takenat', default='')
|
||||
parser.add_argument('--site', default='shopdb')
|
||||
parser.add_argument('--imagetag', default='source')
|
||||
parser.add_argument('--migrations', default='/app/migrations')
|
||||
parser.add_argument('--plugins', default='/app/plugins')
|
||||
parser.add_argument('--datarev')
|
||||
parser.add_argument('--dataplugins',
|
||||
help='JSON object of {plugin: applied revision}')
|
||||
parser.add_argument('--ischeckpassed')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.amend:
|
||||
return amend_manifest(args)
|
||||
if args.out:
|
||||
return write_manifest(args)
|
||||
parser.error('one of --out or --amend is required')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
290
scripts/backup/restore.sh
Executable file
290
scripts/backup/restore.sh
Executable file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env bash
|
||||
# ShopDB restore.
|
||||
#
|
||||
# The DANGEROUS half of the pair, so it is deliberately guarded. A restore is
|
||||
# only safe onto code whose schema is at least the set's - otherwise restored
|
||||
# rows reference columns the live schema has not applied yet and every query on
|
||||
# those tables 500s. This script refuses an incompatible restore rather than
|
||||
# leaving the site half-broken.
|
||||
#
|
||||
# Usage (from the backup container):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
|
||||
# run --rm --entrypoint /opt/backup/restore.sh backup <snapshot-set-name>
|
||||
#
|
||||
# It does NOT roll back code - that is git's job (git checkout <tag> && build).
|
||||
# Restore code FIRST, bring the schema up (flask db upgrade + plugin
|
||||
# upgrade-all), THEN run this to load the data.
|
||||
#
|
||||
# ASCII only, no emojis - matches the repo style gate.
|
||||
set -euo pipefail
|
||||
|
||||
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
|
||||
SETNAME="${1:-}"
|
||||
|
||||
# Overridable so the gate can be exercised outside a container against throwaway
|
||||
# migration trees. In the image these are exactly the mounted paths, so the
|
||||
# shipped behaviour is unchanged; test-restore-datarev.sh drives THIS script
|
||||
# rather than a copy of its logic, which is the only way the test can notice
|
||||
# this script drifting.
|
||||
BACKUP_LIB_DIR="${BACKUP_LIB_DIR:-/opt/backup}"
|
||||
APP_MIGRATIONS_DIR="${APP_MIGRATIONS_DIR:-/app/migrations}"
|
||||
APP_PLUGINS_DIR="${APP_PLUGINS_DIR:-/app/plugins}"
|
||||
APP_INSTANCE_DIR="${APP_INSTANCE_DIR:-/app/instance}"
|
||||
|
||||
# When set, stop after the compatibility gate and report the verdict as the exit
|
||||
# code, touching nothing. This is what makes the gate testable.
|
||||
RESTORE_CHECK_ONLY="${RESTORE_CHECK_ONLY:-}"
|
||||
|
||||
# shellcheck source=scripts/backup/status.sh
|
||||
. "${BACKUP_LIB_DIR}/status.sh"
|
||||
|
||||
log() { echo "[restore] $*"; }
|
||||
|
||||
# On any exit, stamp the terminal phase and append a history record so the UI
|
||||
# reflects success or failure even when a step aborts early (set -e). A restore
|
||||
# is only "done" once the DATA has been upgraded to the running CODE, and that
|
||||
# upgrade runs in the Flask-capable api container, not here (this sidecar has no
|
||||
# app). So the happy path ends by handing off to the api: this script stamps an
|
||||
# 'upgrading' phase and drops an .upgrade-requested sentinel, and the api runs
|
||||
# 'flask db upgrade' + 'flask plugin upgrade-all' and writes the final 'done'.
|
||||
RESTORE_LOADED=false
|
||||
_on_exit() {
|
||||
code=$?
|
||||
if [ "${RESTORE_LOADED}" = true ]; then
|
||||
# Data is loaded; the api takes it from 'upgrading' to 'done'. Do not
|
||||
# stamp success here or the UI would call the restore complete before
|
||||
# the schema is actually brought up to code.
|
||||
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
|
||||
else
|
||||
status_write restore failed "${SETNAME}" "Restore failed (exit ${code})"
|
||||
history_append restore failed "${SETNAME}" "Restore failed (exit ${code})"
|
||||
fi
|
||||
}
|
||||
trap _on_exit EXIT
|
||||
|
||||
die() { echo "[restore] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
[ -n "${SETNAME}" ] || die "usage: restore.sh <snapshot-set-name>"
|
||||
|
||||
status_write restore checking "${SETNAME}" "Checking schema compatibility"
|
||||
|
||||
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
|
||||
DBDUMP="${SETDIR}/db.sql.gz"
|
||||
|
||||
# Prefer the local fast path; fall back to pulling the set out of restic.
|
||||
if [ ! -f "${DBDUMP}" ]; then
|
||||
log "local dump not found; restoring set from restic"
|
||||
command -v restic >/dev/null 2>&1 || die "restic not available and no local dump"
|
||||
restic restore latest --tag "${SETNAME}" --target / --include "${SETDIR}" \
|
||||
|| die "restic restore of ${SETNAME} failed"
|
||||
fi
|
||||
[ -f "${DBDUMP}" ] || die "no db.sql.gz for set ${SETNAME}"
|
||||
|
||||
# Compatibility gate: the restore must gate on the revision the DUMP'S DATA is
|
||||
# at (datarev), not on a code-to-code comparison. Loading data that sits at an
|
||||
# OLDER revision than the running code is fine - we upgrade it afterwards - but
|
||||
# loading data NEWER than the code (a revision the code has never heard of)
|
||||
# would leave rows referencing columns the code cannot migrate to, so that is
|
||||
# refused.
|
||||
log "checking schema compatibility"
|
||||
|
||||
# The set's data revision: prefer the manifest field; for older manifests that
|
||||
# predate datarev, parse the real alembic_version straight out of the dump so a
|
||||
# legacy set is still gated on its true data, not on a code-derived stamp.
|
||||
SET_DATAREV="$(python3 -c \
|
||||
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
|
||||
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
||||
if [ -z "${SET_DATAREV}" ]; then
|
||||
log "manifest has no datarev (legacy set) - reading it from the dump"
|
||||
SET_DATAREV="$(gunzip -c "${DBDUMP}" \
|
||||
| grep -A2 -i 'INSERT INTO `alembic_version`' \
|
||||
| grep -oE "'[0-9a-zA-Z_]+'" | tr -d "'" | head -1)"
|
||||
fi
|
||||
|
||||
if [ -z "${SET_DATAREV}" ]; then
|
||||
die "cannot determine the set's data revision - refusing an unverified restore"
|
||||
fi
|
||||
|
||||
|
||||
# Does the running code know this revision at all? If a migration file with that
|
||||
# revision id exists in the core chain, the code is at-or-past it (the head is
|
||||
# the tip of the same chain), so a post-load upgrade can carry the data forward.
|
||||
# If it is absent, the dump is NEWER than the code - refuse.
|
||||
if ! grep -rqE "^revision = ['\"]${SET_DATAREV}['\"]" "${APP_MIGRATIONS_DIR}/versions" 2>/dev/null; then
|
||||
log "set datarev : ${SET_DATAREV}"
|
||||
die "the backup's data is at a revision this code does not have - deploy code at or past '${SET_DATAREV}' before restoring"
|
||||
fi
|
||||
log "data revision ${SET_DATAREV} is known to this code - restore may proceed"
|
||||
|
||||
# The same gate for every PLUGIN chain, because the core chain alone cannot
|
||||
# answer this. Plugin schemas move independently of core: roll the code back to
|
||||
# an older plugin revision and the core head can be identical while the dump
|
||||
# carries plugin tables the code has never heard of. Gating on core only let
|
||||
# that pass, load the dump, and then fail in 'flask plugin upgrade-all' with
|
||||
# "Can't locate revision" and the data already replaced - a failed restore that
|
||||
# has destroyed what it was restoring over.
|
||||
#
|
||||
# A manifest with no dataplugins is a legacy set taken before this was stamped.
|
||||
# Its plugin revisions are unknowable, so it is allowed through on the core gate
|
||||
# alone (the behaviour before this check existed) rather than being made
|
||||
# unrestorable retroactively.
|
||||
SET_DATAPLUGINS="$(python3 -c \
|
||||
'import json,sys
|
||||
try:
|
||||
data = json.load(open(sys.argv[1])).get("dataplugins") or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
for name, rev in sorted(data.items()):
|
||||
print(name + " " + rev)' \
|
||||
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
||||
|
||||
if [ -z "${SET_DATAPLUGINS}" ]; then
|
||||
log "manifest stamps no plugin revisions (legacy set) - core gate only"
|
||||
else
|
||||
while read -r plugin rev; do
|
||||
[ -n "${plugin}" ] || continue
|
||||
chain="${APP_PLUGINS_DIR}/${plugin}/migrations/versions"
|
||||
if [ ! -d "${chain}" ]; then
|
||||
die "the backup carries data for plugin '${plugin}', which this build does not have - deploy a build that includes it before restoring"
|
||||
fi
|
||||
if ! grep -rqE "^revision = ['\"]${rev}['\"]" "${chain}" 2>/dev/null; then
|
||||
log "plugin ${plugin} data revision: ${rev}"
|
||||
die "plugin '${plugin}' data is at a revision this code does not have - deploy code at or past '${rev}' before restoring"
|
||||
fi
|
||||
log "plugin ${plugin} at ${rev} - known to this code"
|
||||
done <<PLUGINREVS
|
||||
${SET_DATAPLUGINS}
|
||||
PLUGINREVS
|
||||
fi
|
||||
|
||||
# Last gate: which schema does this dump target? A dump taken with --databases
|
||||
# carries CREATE DATABASE and USE <name>, pinning it to the schema it came from.
|
||||
# Loaded into a site whose MYSQL_DATABASE differs, the rows land in the dump's
|
||||
# schema, the live database is untouched, and the restore reports success. New
|
||||
# sets are dumped without it and load into whatever schema is named here; older
|
||||
# sets are still bound, so refuse rather than restore beside the live data.
|
||||
# Checked HERE, with the other gates, so it costs nothing and stops before the
|
||||
# safety copy rather than after it.
|
||||
DUMP_SCHEMA="$(gunzip -c "${DBDUMP}" \
|
||||
| grep -m1 -oE '^USE `[^`]+`' \
|
||||
| sed -E 's/^USE `(.*)`/\1/' || true)"
|
||||
if [ -n "${DUMP_SCHEMA}" ]; then
|
||||
if [ "${DUMP_SCHEMA}" != "${MYSQL_DATABASE}" ]; then
|
||||
die "this set is bound to schema '${DUMP_SCHEMA}' but this site's database is '${MYSQL_DATABASE}' - restoring it would load the data beside the live database and report success"
|
||||
fi
|
||||
log "legacy schema-bound dump for '${DUMP_SCHEMA}' - matches this site"
|
||||
fi
|
||||
|
||||
if [ -n "${RESTORE_CHECK_ONLY}" ]; then
|
||||
log "check-only: every gate passed, stopping before anything destructive"
|
||||
RESTORE_LOADED=false
|
||||
trap - EXIT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Safety set FIRST. Everything below this point is destructive and none of it
|
||||
# is transactional: the dump carries DROP TABLE per table, and the instance tree
|
||||
# is replaced wholesale. Without a copy taken here, a restore of the wrong set -
|
||||
# or a failure part-way through the load - leaves nothing to go back to. This is
|
||||
# a local copy on the same volume, so it is cheap and it is NOT a substitute for
|
||||
# a real backup; it exists to make THIS operation reversible.
|
||||
SAFETY_STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
||||
SAFETY_DIR="${BACKUPS_ROOT}/pre-restore-${SAFETY_STAMP}"
|
||||
status_write restore safety "${SETNAME}" "Copying the current state aside"
|
||||
log "taking a pre-restore safety copy into ${SAFETY_DIR}"
|
||||
mkdir -p "${SAFETY_DIR}" || die "could not create ${SAFETY_DIR}"
|
||||
|
||||
mysqldump \
|
||||
--host="${MYSQL_HOST}" \
|
||||
--user="${MYSQL_USER}" \
|
||||
--password="${MYSQL_PASSWORD}" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--no-tablespaces \
|
||||
"${MYSQL_DATABASE}" \
|
||||
| gzip -c > "${SAFETY_DIR}/db.sql.gz" \
|
||||
|| die "pre-restore dump failed - refusing to touch the live database"
|
||||
|
||||
if [ -d /app/instance ]; then
|
||||
tar -czf "${SAFETY_DIR}/instance.tar.gz" -C /app instance \
|
||||
|| die "pre-restore instance copy failed - refusing to touch the live files"
|
||||
fi
|
||||
|
||||
# Named so an operator finding this directory months later knows what it is and
|
||||
# how to use it, without reading this script.
|
||||
cat > "${SAFETY_DIR}/README.txt" <<SAFETYNOTE
|
||||
Automatic pre-restore copy of the state this site was in immediately before
|
||||
restoring set '${SETNAME}' at ${SAFETY_STAMP}.
|
||||
|
||||
db.sql.gz mysqldump of ${MYSQL_DATABASE} as it was
|
||||
instance.tar.gz /app/instance as it was (absent if there was no such tree)
|
||||
|
||||
To go back to this state, load the dump the same way a restore does:
|
||||
|
||||
gunzip -c db.sql.gz | mysql --host=... --user=... --password=... \
|
||||
--database=${MYSQL_DATABASE}
|
||||
tar -xzf instance.tar.gz -C /app
|
||||
|
||||
This is a local copy on the backups volume, taken for reversibility of one
|
||||
restore. It is not a backup set: it has no manifest and no restic snapshot, and
|
||||
nothing prunes it.
|
||||
SAFETYNOTE
|
||||
log "safety copy written ($(du -sh "${SAFETY_DIR}" | cut -f1))"
|
||||
|
||||
# Files first, then DB, so the two land together.
|
||||
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
|
||||
status_write restore files "${SETNAME}" "Restoring instance files"
|
||||
# Scoped to THIS set's snapshot. A bare 'latest' restores whatever was
|
||||
# snapshotted most recently, so restoring anything other than the newest set
|
||||
# paired that set's DATABASE with the NEWEST snapshot's uploads and
|
||||
# plugins.json - a mismatch nothing downstream could detect.
|
||||
log "restoring instance files from the ${SETNAME} snapshot"
|
||||
STAGE="$(mktemp -d)"
|
||||
restic restore latest --tag "${SETNAME}" --target "${STAGE}" \
|
||||
--include /app/instance \
|
||||
|| die "restic restore of instance files for ${SETNAME} failed"
|
||||
|
||||
# Replace the CONTENTS of the instance tree rather than restoring over it,
|
||||
# so files created after the snapshot are gone afterwards - a restore has to
|
||||
# put the site back to the snapshot, not merge into it. Done as an explicit
|
||||
# staged swap rather than 'restic restore --delete --target /', because that
|
||||
# aims a delete pass at the container root and the blast radius of getting
|
||||
# its filter wrong is the whole filesystem. /app/instance is a mount, so the
|
||||
# directory itself has to survive; only what is inside it is replaced.
|
||||
if [ -d "${STAGE}/app/instance" ]; then
|
||||
find /app/instance -mindepth 1 -delete \
|
||||
|| die "could not clear the instance tree before restoring it"
|
||||
cp -a "${STAGE}/app/instance/." /app/instance/ \
|
||||
|| die "could not lay down the restored instance tree"
|
||||
else
|
||||
log "WARN snapshot for ${SETNAME} carried no /app/instance - leaving files alone"
|
||||
fi
|
||||
rm -rf "${STAGE}"
|
||||
fi
|
||||
|
||||
status_write restore database "${SETNAME}" "Loading database dump"
|
||||
|
||||
|
||||
log "loading database dump into ${MYSQL_DATABASE}"
|
||||
gunzip -c "${DBDUMP}" | mysql \
|
||||
--host="${MYSQL_HOST}" \
|
||||
--user="${MYSQL_USER}" \
|
||||
--password="${MYSQL_PASSWORD}" \
|
||||
--database="${MYSQL_DATABASE}" \
|
||||
|| die "database load failed"
|
||||
|
||||
# Hand off to the api container for the schema upgrade. The dump may sit at an
|
||||
# older revision than this image (allowed and expected); without the upgrade the
|
||||
# data stays behind the code and every query on a table the newer migrations
|
||||
# changed 500s with 1054 Unknown column. This sidecar has no Flask, so it cannot
|
||||
# run 'flask db upgrade' itself - it records the intent and lets the api (which
|
||||
# owns the app context and already runs these exact idempotent commands on every
|
||||
# deploy) finalize. The api clears .upgrade-requested and stamps the final
|
||||
# 'done' once the upgrade succeeds.
|
||||
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
|
||||
printf '%s' "${SETNAME}" > "${BACKUPS_ROOT}/.upgrade-requested"
|
||||
log "database loaded at revision ${SET_DATAREV}; handed off to api for schema upgrade"
|
||||
|
||||
rm -f "${BACKUPS_ROOT}/.restore-requested" 2>/dev/null || true
|
||||
RESTORE_LOADED=true
|
||||
235
scripts/backup/simulate-restore.sh
Executable file
235
scripts/backup/simulate-restore.sh
Executable file
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bash
|
||||
# ShopDB restore simulation harness. ***FOR DEVELOPER USE ONLY***
|
||||
#
|
||||
# This is a developer diagnostic, NOT an operator tool and NOT part of the
|
||||
# shipped backup feature. It mutates a live database on purpose. Do not run it
|
||||
# against production or any environment you are not prepared to disturb.
|
||||
#
|
||||
# WHY: the restore path reloads the WHOLE database in one pass (mysqldump
|
||||
# --databases emits DROP TABLE + CREATE TABLE + full INSERTs, so restore is a
|
||||
# full replace, not a row-level merge). The strongest real-world check is a
|
||||
# full-fidelity round trip: fingerprint every table, take a backup, mutate
|
||||
# across the table archetypes (core standalone, asset + plugin subtype, and
|
||||
# consumable ledgers), restore, then prove every table's fingerprint returns to
|
||||
# the backup-time state.
|
||||
#
|
||||
# It drives the exact sentinel path the UI uses (.backup-requested /
|
||||
# .restore-requested on the shared volume), not a private code path, so a pass
|
||||
# here is a pass for the operator-facing feature.
|
||||
#
|
||||
# KNOWN, EXPECTED, NOT-A-FAILURE behaviors (see LiveDatabaseTest notes below):
|
||||
# - apitokens does not round-trip bit-for-bit: the app rewrites token
|
||||
# last-used timestamps continuously, so restore reproduces backup-time
|
||||
# state, which differs from any later fingerprint. This is correct.
|
||||
# - endpoint-agent tables (computers heartbeat, endpointstates/events/
|
||||
# applications) churn from the EXTERNAL fleet agents, which cannot be
|
||||
# quiesced. Post-restore writes from them are expected drift, not a
|
||||
# restore defect.
|
||||
# - Asset ROWS cannot be deleted in a live/CMMC environment (RESTRICT and
|
||||
# compliance controls). The cascade probe is therefore informational only;
|
||||
# restore fidelity does not depend on it. A missing/corrupted asset is
|
||||
# still fully restored because the dump drops and recreates the table.
|
||||
#
|
||||
# Usage (from a host with the stack up, git bash):
|
||||
# bash scripts/backup/simulate-restore.sh
|
||||
#
|
||||
# ASCII only, no emojis - matches the repo style gate.
|
||||
set -uo pipefail
|
||||
|
||||
# Silence the docker CLI "What's next" hints - they bleed into command
|
||||
# substitution and corrupt captured SQL output.
|
||||
export DOCKER_CLI_HINTS=false
|
||||
|
||||
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Tables that legitimately differ after restore and MUST NOT be counted as
|
||||
# failures:
|
||||
# - App-churn: token last-used, sessions, audit trail.
|
||||
# - Endpoint-agent churn: the external fleet agents keep writing to these
|
||||
# after restore (heartbeats, state, events, inventory) and cannot be
|
||||
# quiesced from here.
|
||||
# Restore reproduces their state at dump time; later fingerprints differ by
|
||||
# design, so they are reported informationally.
|
||||
VOLATILE="apitokens sessions auditlogs computers endpointstates endpointevents endpointapplications"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
BASELINE="${WORKDIR}/baseline.txt"
|
||||
MUTATED="${WORKDIR}/mutated.txt"
|
||||
RESTORED="${WORKDIR}/restored.txt"
|
||||
MARKER="simrestore-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
log() { echo "[sim] $*"; }
|
||||
die() { echo "[sim] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# Run a SQL statement, returning tab-separated rows with no column headers.
|
||||
sql() {
|
||||
printf '%s\n' "$1" | ${COMPOSE} exec -T db sh -c \
|
||||
'mysql -ushopdb -p"$MYSQL_PASSWORD" shopdb_flask -N 2>/dev/null'
|
||||
}
|
||||
|
||||
# Fingerprint every base table with CHECKSUM TABLE into the given file.
|
||||
fingerprint() {
|
||||
out="$1"
|
||||
tables="$(sql "SELECT table_name FROM information_schema.tables \
|
||||
WHERE table_schema='shopdb_flask' AND table_type='BASE TABLE' \
|
||||
ORDER BY table_name;" | tr -d '\r')"
|
||||
: > "${out}"
|
||||
for table in ${tables}; do
|
||||
# CHECKSUM TABLE returns "dbname.table<TAB>checksum"; keep table+checksum.
|
||||
line="$(sql "CHECKSUM TABLE \`${table}\`;" | tr -d '\r')"
|
||||
checksum="$(printf '%s' "${line}" | awk '{print $2}')"
|
||||
printf '%s\t%s\n' "${table}" "${checksum}" >> "${out}"
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_phase() {
|
||||
kind="$1"; want="$2"; timeout="${3:-120}"
|
||||
waited=0
|
||||
while [ "${waited}" -lt "${timeout}" ]; do
|
||||
phase="$(${COMPOSE} exec -T backup-watcher sh -c \
|
||||
"cat /backups/.${kind}-status.json 2>/dev/null" \
|
||||
| sed -n 's/.*\"phase\":\"\([^\"]*\)\".*/\1/p')"
|
||||
if [ "${phase}" = "${want}" ]; then return 0; fi
|
||||
if [ "${phase}" = "failed" ]; then
|
||||
die "${kind} reported failed status"
|
||||
fi
|
||||
sleep 3
|
||||
waited=$((waited + 3))
|
||||
done
|
||||
die "${kind} did not reach '${want}' within ${timeout}s (last: ${phase:-none})"
|
||||
}
|
||||
|
||||
# --- 0. Preconditions ------------------------------------------------------
|
||||
${COMPOSE} exec -T db sh -c 'true' >/dev/null 2>&1 \
|
||||
|| die "db container not reachable - is the stack up?"
|
||||
|
||||
# --- 0b. Datarev-gate preflight (external tester) --------------------------
|
||||
# This live round trip runs one codebase against itself, so it can never
|
||||
# reproduce the "data revision behind the running code" case the restore gate
|
||||
# now handles. Run the standalone datarev tester first so a pass here also
|
||||
# covers that offline contract (manifest datarev, known-vs-unknown revision
|
||||
# refusal, legacy fallback, and the api upgrade handoff).
|
||||
log "running datarev gate preflight (test-restore-datarev.sh)"
|
||||
bash "${SCRIPT_DIR}/test-restore-datarev.sh" \
|
||||
|| die "datarev gate preflight failed - see output above"
|
||||
log "datarev gate preflight passed"
|
||||
|
||||
# --- 1. Backup the current state, then fingerprint the RESTORE TARGET ------
|
||||
# The restore point is the database as it existed WHEN THE DUMP RAN, not some
|
||||
# earlier moment. Live-churn tables (apitokens last-used, sessions, auditlogs)
|
||||
# move under the running app, so the baseline must be captured immediately
|
||||
# after the backup completes - that is the exact state restore must reproduce.
|
||||
log "requesting a backup (sentinel .backup-requested)"
|
||||
${COMPOSE} exec -T backup-watcher sh -c 'echo sim > /backups/.backup-requested'
|
||||
wait_for_phase backup done 300
|
||||
SETNAME="$(${COMPOSE} exec -T backup-watcher sh -c \
|
||||
'cat /backups/.backup-status.json 2>/dev/null' \
|
||||
| sed -n 's/.*\"setname\":\"\([^\"]*\)\".*/\1/p')"
|
||||
[ -n "${SETNAME}" ] || die "could not read the completed backup set name"
|
||||
log "backup complete: ${SETNAME}"
|
||||
|
||||
log "baseline fingerprint of all tables (restore target)"
|
||||
fingerprint "${BASELINE}"
|
||||
baseline_count="$(wc -l < "${BASELINE}")"
|
||||
log "fingerprinted ${baseline_count} tables"
|
||||
|
||||
# Quiesce the app for the mutate/restore/verify window. A real DR runbook stops
|
||||
# serving traffic before a restore; here it also stops the endpoint-agent write
|
||||
# path (computers heartbeat, endpointstates/events/applications) that would
|
||||
# otherwise churn the DB concurrently and defeat a bit-for-bit comparison.
|
||||
log "stopping api to quiesce writers"
|
||||
${COMPOSE} stop api >/dev/null 2>&1
|
||||
resume_api() { log "resuming api"; ${COMPOSE} start api >/dev/null 2>&1; }
|
||||
trap resume_api EXIT
|
||||
|
||||
# --- 2. Mutate across the table archetypes ---------------------------------
|
||||
# Core standalone: rename a vendor and flip a setting.
|
||||
# Ledger/consumable: bump a modelsupplies quantityonhand.
|
||||
# NOTE: asset ROW deletion is intentionally NOT performed. In a live/CMMC
|
||||
# environment assets cannot be removed (RESTRICT + compliance controls), which
|
||||
# is the app behaving as designed. Restore fidelity does not depend on it: the
|
||||
# dump drops and recreates every table, so a missing or corrupted asset is
|
||||
# fully reconstructed regardless. The cascade edge is reported informationally.
|
||||
log "mutating rows (marker ${MARKER})"
|
||||
|
||||
sql "UPDATE vendors SET vendorname=CONCAT('${MARKER}-', vendorname) \
|
||||
ORDER BY vendorid LIMIT 1;"
|
||||
sql "UPDATE settings SET settingvalue='${MARKER}' \
|
||||
WHERE settingkey=(SELECT settingkey FROM (SELECT settingkey FROM settings \
|
||||
ORDER BY settingkey LIMIT 1) AS s);"
|
||||
|
||||
# Cascade edge (informational only - no destructive delete): report the FK
|
||||
# delete rule that would remove computer subtype rows if an asset were deleted.
|
||||
CASCADE_RULE="$(sql "SELECT delete_rule FROM information_schema.referential_constraints \
|
||||
WHERE constraint_schema='shopdb_flask' AND referenced_table_name='assets' \
|
||||
AND table_name='computers' LIMIT 1;" | tr -d '\r')"
|
||||
log "cascade edge computers->assets delete_rule=${CASCADE_RULE:-unknown} (live asset delete not permitted; not exercised)"
|
||||
|
||||
# Ledger: move a cached quantityonhand so the restore must revert it.
|
||||
sql "UPDATE modelsupplies SET quantityonhand=quantityonhand+9999 \
|
||||
ORDER BY id LIMIT 1;" 2>/dev/null \
|
||||
|| sql "UPDATE modelsupplies SET quantityonhand=quantityonhand+9999 \
|
||||
ORDER BY modelsupplyid LIMIT 1;"
|
||||
|
||||
log "post-mutation fingerprint"
|
||||
fingerprint "${MUTATED}"
|
||||
changed="$(diff <(sort "${BASELINE}") <(sort "${MUTATED}") | grep -c '^>')"
|
||||
log "mutation changed ${changed} table fingerprint(s)"
|
||||
|
||||
# --- 3. Restore the backup we took -----------------------------------------
|
||||
log "requesting restore of ${SETNAME}"
|
||||
${COMPOSE} exec -T backup-watcher sh -c "printf '%s' '${SETNAME}' > /backups/.restore-requested"
|
||||
wait_for_phase restore done 600
|
||||
log "restore complete"
|
||||
|
||||
# --- 4. Verify every table returned to baseline ----------------------------
|
||||
log "post-restore fingerprint"
|
||||
fingerprint "${RESTORED}"
|
||||
|
||||
fails=0
|
||||
volatile_diffs=0
|
||||
while IFS="$(printf '\t')" read -r table base; do
|
||||
restored="$(grep -P "^${table}\t" "${RESTORED}" | awk '{print $2}')"
|
||||
if [ "${base}" != "${restored}" ]; then
|
||||
case " ${VOLATILE} " in
|
||||
*" ${table} "*)
|
||||
echo "[sim] (volatile) ${table} differs as expected: baseline=${base} restored=${restored}"
|
||||
volatile_diffs=$((volatile_diffs + 1))
|
||||
;;
|
||||
*)
|
||||
echo "[sim] MISMATCH ${table}: baseline=${base} restored=${restored}"
|
||||
fails=$((fails + 1))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done < "${BASELINE}"
|
||||
|
||||
echo "=================================================="
|
||||
RESULTS_FILE="${SCRIPT_DIR}/simulate-restore-results.txt"
|
||||
{
|
||||
echo "ShopDB restore simulation results (FOR DEVELOPER USE ONLY)"
|
||||
echo "run (UTC) : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "marker : ${MARKER}"
|
||||
echo "backup set : ${SETNAME}"
|
||||
echo "tables checked : ${baseline_count}"
|
||||
echo "cascade edge : computers->assets delete_rule=${CASCADE_RULE:-unknown} (not exercised - live asset delete not permitted)"
|
||||
echo ""
|
||||
echo "Expected, not-a-failure drift (excluded from strict equality):"
|
||||
echo " - apitokens: app rewrites token last-used; restore is correct at backup time."
|
||||
echo " - endpoint-agent tables (computers, endpointstates/events/applications):"
|
||||
echo " external fleet agents keep writing post-restore and cannot be quiesced."
|
||||
echo " - asset row deletion is not permitted live (RESTRICT + CMMC); the table is"
|
||||
echo " still fully restored because the dump drops and recreates it."
|
||||
echo ""
|
||||
if [ "${fails}" -eq 0 ]; then
|
||||
echo "RESULT: PASS - all ${baseline_count} tables restored to backup-time state"
|
||||
echo " (${volatile_diffs} volatile table(s) excluded from strict equality)"
|
||||
else
|
||||
echo "RESULT: FAIL - ${fails} table(s) did not match after restore"
|
||||
fi
|
||||
} | tee "${RESULTS_FILE}"
|
||||
echo "=================================================="
|
||||
echo "[sim] results written to ${RESULTS_FILE}"
|
||||
echo "[sim] work dir: ${WORKDIR}"
|
||||
echo "=================================================="
|
||||
[ "${fails}" -eq 0 ]
|
||||
100
scripts/backup/status.sh
Executable file
100
scripts/backup/status.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
# ShopDB backup/restore status + history helper.
|
||||
#
|
||||
# WHY this exists: the restore and backup runs happen in the watcher container,
|
||||
# but the operator watches the UI, which only talks to the api. This helper is
|
||||
# the bridge: it writes two kinds of state onto the shared /backups volume that
|
||||
# the api can read.
|
||||
#
|
||||
# 1. A LIVE status file (.restore-status.json / .backup-status.json) rewritten
|
||||
# at each phase so the UI can show an indeterminate spinner with a phase
|
||||
# label (queued, restoring files, loading database, done, failed).
|
||||
# 2. An append-only HISTORY file (history/restores.json / history/backups.json)
|
||||
# that keeps the record of past runs so the UI can list them in a card.
|
||||
#
|
||||
# Both writes are atomic (write temp, rename) so the api never reads a half
|
||||
# written file. History is bounded to the most recent HISTORY_MAX entries.
|
||||
#
|
||||
# This file is SOURCED, not executed. It exposes:
|
||||
# status_write <kind> <phase> <setname> [message]
|
||||
# history_append <kind> <outcome> <setname> [message]
|
||||
#
|
||||
# kind is "restore" or "backup". ASCII only, no emojis - matches the style gate.
|
||||
|
||||
STATUS_BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
|
||||
HISTORY_MAX="${BACKUP_HISTORY_MAX:-50}"
|
||||
|
||||
_status_now() { date -u +%Y-%m-%dT%H%M%SZ; }
|
||||
|
||||
_status_json_escape() {
|
||||
# Escape backslashes and double quotes for embedding in a JSON string.
|
||||
printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
# status_write <kind> <phase> <setname> [message]
|
||||
# Rewrites the live status file for this run kind.
|
||||
status_write() {
|
||||
kind="$1"
|
||||
phase="$2"
|
||||
setname="${3:-}"
|
||||
message="${4:-}"
|
||||
statusfile="${STATUS_BACKUPS_ROOT}/.${kind}-status.json"
|
||||
tmpfile="${statusfile}.tmp.$$"
|
||||
{
|
||||
printf '{'
|
||||
printf '"kind":"%s",' "$(_status_json_escape "${kind}")"
|
||||
printf '"phase":"%s",' "$(_status_json_escape "${phase}")"
|
||||
printf '"setname":"%s",' "$(_status_json_escape "${setname}")"
|
||||
printf '"message":"%s",' "$(_status_json_escape "${message}")"
|
||||
printf '"updatedat":"%s"' "$(_status_now)"
|
||||
printf '}\n'
|
||||
} > "${tmpfile}" 2>/dev/null || return 0
|
||||
mv -f "${tmpfile}" "${statusfile}" 2>/dev/null || rm -f "${tmpfile}" 2>/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
# history_append <kind> <outcome> <setname> [message]
|
||||
# Appends a completed run record and trims to HISTORY_MAX newest entries.
|
||||
history_append() {
|
||||
kind="$1"
|
||||
outcome="$2"
|
||||
setname="${3:-}"
|
||||
message="${4:-}"
|
||||
historydir="${STATUS_BACKUPS_ROOT}/history"
|
||||
historyfile="${historydir}/${kind}s.json"
|
||||
tmpfile="${historyfile}.tmp.$$"
|
||||
mkdir -p "${historydir}" 2>/dev/null || return 0
|
||||
|
||||
record="$(printf '{"setname":"%s","outcome":"%s","message":"%s","finishedat":"%s"}' \
|
||||
"$(_status_json_escape "${setname}")" \
|
||||
"$(_status_json_escape "${outcome}")" \
|
||||
"$(_status_json_escape "${message}")" \
|
||||
"$(_status_now)")"
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
HISTORY_FILE="${historyfile}" HISTORY_RECORD="${record}" \
|
||||
HISTORY_MAX="${HISTORY_MAX}" python3 - "${tmpfile}" <<'PY' 2>/dev/null || return 0
|
||||
import json, os, sys
|
||||
|
||||
path = os.environ["HISTORY_FILE"]
|
||||
record = json.loads(os.environ["HISTORY_RECORD"])
|
||||
limit = int(os.environ.get("HISTORY_MAX", "50"))
|
||||
tmp = sys.argv[1]
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
entries = json.load(handle)
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
except (OSError, ValueError):
|
||||
entries = []
|
||||
|
||||
entries.append(record)
|
||||
entries = entries[-limit:]
|
||||
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
json.dump(entries, handle)
|
||||
os.replace(tmp, path)
|
||||
PY
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
231
scripts/backup/test-restore-datarev.sh
Executable file
231
scripts/backup/test-restore-datarev.sh
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
# ShopDB restore datarev-gate tester. ***FOR DEVELOPER USE ONLY***
|
||||
#
|
||||
# WHY THIS EXISTS SEPARATELY FROM simulate-restore.sh:
|
||||
# simulate-restore.sh is a full-fidelity ROUND TRIP against a live stack: it
|
||||
# backs up, mutates, restores, and proves every table returns to backup-time
|
||||
# state. By construction it runs ONE codebase against ITSELF, so the database it
|
||||
# dumps is always at the SAME revision as the code that restores it. It can
|
||||
# therefore never exercise the case the FBJXBDB4 outage was about: a dump whose
|
||||
# DATA revision is BEHIND (or ahead of) the running CODE.
|
||||
#
|
||||
# This tester fills that gap offline. It builds throwaway migration trees and
|
||||
# fake gzipped dumps and drives the exact decision logic restore.sh uses:
|
||||
# 1. manifest.py records datarev (the DB's real alembic_version), not just the
|
||||
# code head (schemarev).
|
||||
# 2. A dump whose datarev the code KNOWS (an ancestor in the chain) is allowed
|
||||
# - the post-load upgrade rolls it forward.
|
||||
# 3. A dump whose datarev the code does NOT know (newer than this build) is
|
||||
# REFUSED - restoring it would 500 on 1054 Unknown column.
|
||||
# 4. A legacy manifest with no datarev falls back to parsing alembic_version
|
||||
# straight out of the dump.
|
||||
# 5. restore.sh hands the loaded restore off to the api via the
|
||||
# .upgrade-requested sentinel instead of marking it done in the sidecar.
|
||||
#
|
||||
# No Docker, no MySQL, no network: it is a pure logic gate test, safe to run in
|
||||
# CI and from inside simulate-restore.sh as a preflight.
|
||||
#
|
||||
# ASCII only, no emojis - matches the repo style gate.
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
MANIFEST_PY="${SCRIPT_DIR}/manifest.py"
|
||||
RESTORE_SH="${SCRIPT_DIR}/restore.sh"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORKDIR}"' EXIT
|
||||
|
||||
# The backup image ships python3; a dev host may only have `python` (and on
|
||||
# Windows `python3` can be a non-functional Store alias that resolves on PATH
|
||||
# but errors when run). Pick the first interpreter that actually EXECUTES, so
|
||||
# this tester runs anywhere without changing the shipped scripts.
|
||||
PY=""
|
||||
for candidate in python3 python py; do
|
||||
if "${candidate}" -c 'pass' >/dev/null 2>&1; then
|
||||
PY="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "${PY}" ] || { echo "[test] ERROR: no working python interpreter found"; exit 1; }
|
||||
|
||||
passes=0
|
||||
fails=0
|
||||
pass() { echo "[test] PASS: $*"; passes=$((passes + 1)); }
|
||||
fail() { echo "[test] FAIL: $*"; fails=$((fails + 1)); }
|
||||
|
||||
# Build a throwaway core migration chain: baseline -> mid -> head. The gate in
|
||||
# restore.sh only needs each version file to declare `revision = '<id>'`, so
|
||||
# that is all we write.
|
||||
build_chain() {
|
||||
root="$1"
|
||||
versions="${root}/versions"
|
||||
mkdir -p "${versions}"
|
||||
printf "revision = '%s'\ndown_revision = None\n" \
|
||||
'0001_baseline' > "${versions}/0001_baseline.py"
|
||||
printf "revision = '%s'\ndown_revision = '%s'\n" \
|
||||
'0002_mid' '0001_baseline' > "${versions}/0002_mid.py"
|
||||
printf "revision = '%s'\ndown_revision = '%s'\n" \
|
||||
'0003_head' '0002_mid' > "${versions}/0003_head.py"
|
||||
}
|
||||
|
||||
# Fake a gzipped mysqldump that stamps a given alembic_version, matching the
|
||||
# INSERT shape restore.sh greps for when a legacy manifest lacks datarev.
|
||||
build_dump() {
|
||||
out="$1"; datarev="$2"
|
||||
{
|
||||
echo 'DROP TABLE IF EXISTS `alembic_version`;'
|
||||
echo 'CREATE TABLE `alembic_version` (`version_num` varchar(32));'
|
||||
echo 'INSERT INTO `alembic_version` VALUES'
|
||||
echo "('${datarev}');"
|
||||
} | gzip -c > "${out}"
|
||||
}
|
||||
|
||||
# Build a throwaway plugin chain, shaped like plugins/<name>/migrations/versions.
|
||||
build_plugin_chain() {
|
||||
root="$1"; name="$2"; rev="$3"
|
||||
versions="${root}/${name}/migrations/versions"
|
||||
mkdir -p "${versions}"
|
||||
printf "revision = '%s'\ndown_revision = None\n" \
|
||||
"${rev}" > "${versions}/${rev}.py"
|
||||
}
|
||||
|
||||
# Drive the REAL restore.sh gate. This is the point of the rewrite: the test
|
||||
# used to carry its own copy of the predicate, so it stayed green no matter how
|
||||
# far restore.sh drifted from it. RESTORE_CHECK_ONLY stops the script right
|
||||
# after the gate, so the verdict here IS the shipped verdict.
|
||||
run_gate() {
|
||||
backups_root="$1"; setname="$2"; chain="$3"; plugins="$4"
|
||||
RESTORE_CHECK_ONLY=1 \
|
||||
SHOPDB_BACKUPS_ROOT="${backups_root}" \
|
||||
BACKUP_LIB_DIR="${SCRIPT_DIR}" \
|
||||
APP_MIGRATIONS_DIR="${chain}" \
|
||||
APP_PLUGINS_DIR="${plugins}" \
|
||||
bash "${RESTORE_SH}" "${setname}" >"${WORKDIR}/gate.log" 2>&1
|
||||
}
|
||||
|
||||
CHAIN="${WORKDIR}/migrations"
|
||||
build_chain "${CHAIN}"
|
||||
PLUGINS="${WORKDIR}/plugins"
|
||||
build_plugin_chain "${PLUGINS}" 'endpoints' 'endpoints0002known'
|
||||
ROOT="${WORKDIR}/backups"
|
||||
mkdir -p "${ROOT}"
|
||||
|
||||
# Lay out one set the way a real backup does, under the backups root.
|
||||
make_set() {
|
||||
setname="$1"; datarev="$2"; dumprev="$3"; dataplugins="$4"
|
||||
setdir="${ROOT}/${setname}"
|
||||
mkdir -p "${setdir}"
|
||||
if [ "${datarev}" = "__legacy__" ]; then
|
||||
printf '{"takenat":"2023-06-01T00:00:00Z","schemarev":{"core":["0003_head"]}}' \
|
||||
> "${setdir}/manifest.json"
|
||||
else
|
||||
${PY} "${MANIFEST_PY}" --out "${setdir}/manifest.json" \
|
||||
--migrations "${CHAIN}" --plugins "${PLUGINS}" \
|
||||
--datarev "${datarev}" --dataplugins "${dataplugins}" \
|
||||
--takenat '2024-01-01T00:00:00Z' >/dev/null 2>&1
|
||||
fi
|
||||
build_dump "${setdir}/db.sql.gz" "${dumprev}"
|
||||
printf '%s' "${setdir}"
|
||||
}
|
||||
|
||||
# --- 1. manifest.py records datarev separately from the code head ----------
|
||||
make_set 'set-behind' '0002_mid' '0002_mid' '{}' >/dev/null
|
||||
recorded="$(${PY} -c \
|
||||
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
|
||||
"${ROOT}/set-behind/manifest.json" 2>/dev/null)"
|
||||
head_stamp="$(${PY} -c \
|
||||
'import json,sys;print(json.load(open(sys.argv[1])).get("schemarev",{}).get("core",[]))' \
|
||||
"${ROOT}/set-behind/manifest.json" 2>/dev/null)"
|
||||
if [ "${recorded}" = '0002_mid' ]; then
|
||||
pass "manifest.py records datarev (${recorded}) separate from code head ${head_stamp}"
|
||||
else
|
||||
fail "manifest.py did not record datarev (got '${recorded}')"
|
||||
fi
|
||||
|
||||
# --- 2. datarev the code KNOWS (behind head) is allowed --------------------
|
||||
if run_gate "${ROOT}" 'set-behind' "${CHAIN}" "${PLUGINS}"; then
|
||||
pass "data behind code (0002_mid) is known and allowed to restore"
|
||||
else
|
||||
fail "data behind code was refused: $(tail -1 "${WORKDIR}/gate.log")"
|
||||
fi
|
||||
|
||||
# --- 3. datarev the code does NOT know (newer than build) is refused -------
|
||||
make_set 'set-ahead' '0004_future' '0004_future' '{}' >/dev/null
|
||||
if run_gate "${ROOT}" 'set-ahead' "${CHAIN}" "${PLUGINS}"; then
|
||||
fail "data ahead of code (0004_future) was NOT refused"
|
||||
else
|
||||
pass "data ahead of code (0004_future) is unknown and correctly refused"
|
||||
fi
|
||||
|
||||
# --- 4. legacy manifest (no datarev) falls back to the dump ----------------
|
||||
make_set 'set-legacy' '__legacy__' '0001_baseline' '{}' >/dev/null
|
||||
if run_gate "${ROOT}" 'set-legacy' "${CHAIN}" "${PLUGINS}"; then
|
||||
pass "legacy set with no datarev falls back to the dump revision"
|
||||
else
|
||||
fail "legacy set fallback failed: $(tail -1 "${WORKDIR}/gate.log")"
|
||||
fi
|
||||
|
||||
# --- 5. restore.sh hands off to the api instead of self-completing ---------
|
||||
if grep -q '\.upgrade-requested' "${RESTORE_SH}" \
|
||||
&& grep -q "status_write restore upgrading" "${RESTORE_SH}" \
|
||||
&& ! grep -qE 'status_write restore done' "${RESTORE_SH}"; then
|
||||
pass "restore.sh parks in 'upgrading' and hands off via .upgrade-requested"
|
||||
else
|
||||
fail "restore.sh does not hand off the upgrade to the api"
|
||||
fi
|
||||
|
||||
# --- 6. a PLUGIN chain the code does not know is refused -------------------
|
||||
# The core head is identical in both of these; only the plugin revision differs.
|
||||
# That is the whole failure: a core-only gate cannot tell them apart.
|
||||
make_set 'set-plugin-known' '0002_mid' '0002_mid' '{"endpoints":"endpoints0002known"}' >/dev/null
|
||||
if run_gate "${ROOT}" 'set-plugin-known' "${CHAIN}" "${PLUGINS}"; then
|
||||
pass "a set whose plugin revision the code knows is allowed"
|
||||
else
|
||||
fail "known plugin revision was refused: $(tail -1 "${WORKDIR}/gate.log")"
|
||||
fi
|
||||
|
||||
make_set 'set-plugin-future' '0002_mid' '0002_mid' '{"endpoints":"endpoints0099future"}' >/dev/null
|
||||
if run_gate "${ROOT}" 'set-plugin-future' "${CHAIN}" "${PLUGINS}"; then
|
||||
fail "plugin data ahead of code was NOT refused (core-only gate regression)"
|
||||
else
|
||||
pass "plugin data ahead of code is refused even though the core head matches"
|
||||
fi
|
||||
|
||||
# --- 7. a plugin the build does not have at all is refused -----------------
|
||||
make_set 'set-plugin-missing' '0002_mid' '0002_mid' '{"nosuchplugin":"whatever0001"}' >/dev/null
|
||||
if run_gate "${ROOT}" 'set-plugin-missing' "${CHAIN}" "${PLUGINS}"; then
|
||||
fail "a set carrying data for an absent plugin was NOT refused"
|
||||
else
|
||||
pass "a set carrying data for a plugin this build lacks is refused"
|
||||
fi
|
||||
|
||||
# --- 8. a dump bound to another schema is refused --------------------------
|
||||
# --databases bakes 'USE <name>' in, which silently loaded the rows beside the
|
||||
# live database and reported success.
|
||||
setdir="${ROOT}/set-otherschema"
|
||||
mkdir -p "${setdir}"
|
||||
${PY} "${MANIFEST_PY}" --out "${setdir}/manifest.json" \
|
||||
--migrations "${CHAIN}" --plugins "${PLUGINS}" \
|
||||
--datarev '0002_mid' --dataplugins '{}' \
|
||||
--takenat '2024-01-01T00:00:00Z' >/dev/null 2>&1
|
||||
{
|
||||
echo 'CREATE DATABASE `someothersite`;'
|
||||
echo 'USE `someothersite`;'
|
||||
echo 'INSERT INTO `alembic_version` VALUES'
|
||||
echo "('0002_mid');"
|
||||
} | gzip -c > "${setdir}/db.sql.gz"
|
||||
# Now a gate, so check-only reaches it without needing a database.
|
||||
out="$(RESTORE_CHECK_ONLY=1 SHOPDB_BACKUPS_ROOT="${ROOT}" \
|
||||
BACKUP_LIB_DIR="${SCRIPT_DIR}" \
|
||||
APP_MIGRATIONS_DIR="${CHAIN}" APP_PLUGINS_DIR="${PLUGINS}" \
|
||||
MYSQL_DATABASE='shopdb_flask' \
|
||||
bash "${RESTORE_SH}" 'set-otherschema' 2>&1)" || true
|
||||
if printf '%s' "${out}" | grep -q "bound to schema 'someothersite'"; then
|
||||
pass "a dump bound to another site's schema is refused"
|
||||
else
|
||||
fail "a schema-bound dump was not caught (got: $(printf '%s' "${out}" | tail -1))"
|
||||
fi
|
||||
|
||||
echo "=================================================="
|
||||
echo "[test] datarev gate: ${passes} passed, ${fails} failed"
|
||||
echo "=================================================="
|
||||
[ "${fails}" -eq 0 ]
|
||||
61
scripts/backup/watch.sh
Executable file
61
scripts/backup/watch.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# ShopDB backup WATCHER - the resident half of the in-app backup utility.
|
||||
#
|
||||
# WHY this exists: the api container cannot run docker, so when an operator
|
||||
# clicks "Back up now" the api only drops a sentinel file (.backup-requested)
|
||||
# on the shared backups volume. This watcher is the thing that actuates that
|
||||
# request: it polls for the sentinel and runs the one-shot backup.sh, which
|
||||
# ships a restic snapshot to the backup-store container and clears the sentinel
|
||||
# on success. That turns the UI button into a real, self-serve backup without
|
||||
# giving the api container any docker or host privileges.
|
||||
#
|
||||
# ASCII only, no emojis - matches the repo style gate.
|
||||
set -uo pipefail
|
||||
|
||||
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
|
||||
SENTINEL="${BACKUPS_ROOT}/.backup-requested"
|
||||
RESTORE_SENTINEL="${BACKUPS_ROOT}/.restore-requested"
|
||||
INTERVAL="${BACKUP_POLL_SECONDS:-10}"
|
||||
|
||||
log() { echo "[backup-watch] $*"; }
|
||||
|
||||
# shellcheck source=scripts/backup/status.sh
|
||||
. /opt/backup/status.sh
|
||||
|
||||
mkdir -p "${BACKUPS_ROOT}"
|
||||
# The api container runs unprivileged and only drops sentinel files here; it
|
||||
# cannot write into a root-owned 0755 directory. Make the backups root
|
||||
# writable so "Back up now" and "Restore" can queue their sentinels.
|
||||
chmod 0777 "${BACKUPS_ROOT}" 2>/dev/null || true
|
||||
log "watcher up; polling ${SENTINEL} and ${RESTORE_SENTINEL} every ${INTERVAL}s"
|
||||
|
||||
while true; do
|
||||
if [ -f "${SENTINEL}" ]; then
|
||||
log "backup requested - running one-shot"
|
||||
status_write backup queued "" "Backup queued"
|
||||
if /opt/backup/backup.sh; then
|
||||
log "backup completed"
|
||||
else
|
||||
# backup.sh only clears the sentinel on success, so a failed run
|
||||
# would otherwise loop forever. Clear it here and wait for the next
|
||||
# explicit request rather than hammering a broken backup.
|
||||
log "backup FAILED - clearing request, awaiting next"
|
||||
rm -f "${SENTINEL}" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
if [ -f "${RESTORE_SENTINEL}" ]; then
|
||||
# The api drops the target set name into the restore sentinel. Read it,
|
||||
# hand it to restore.sh, and clear the request whether it succeeds or
|
||||
# fails so a bad set cannot pin the watcher in a tight retry loop.
|
||||
setname="$(head -n1 "${RESTORE_SENTINEL}" 2>/dev/null | tr -d '\r\n')"
|
||||
log "restore requested - running restore for ${setname}"
|
||||
status_write restore queued "${setname}" "Restore queued"
|
||||
if /opt/backup/restore.sh "${setname}"; then
|
||||
log "restore completed"
|
||||
else
|
||||
log "restore FAILED - clearing request, awaiting next"
|
||||
fi
|
||||
rm -f "${RESTORE_SENTINEL}" 2>/dev/null || true
|
||||
fi
|
||||
sleep "${INTERVAL}"
|
||||
done
|
||||
@@ -35,6 +35,10 @@ EXCLUDES=(
|
||||
--exclude-dir=.git
|
||||
--exclude-dir=versions
|
||||
--exclude-dir=staticdocs
|
||||
# Local, gitignored scratch notes (see .gitignore devnotes/). Not product
|
||||
# source, never committed, and free to hold pasted logs full of em-dashes
|
||||
# and arrows - grep does not consult .gitignore, so exclude it explicitly.
|
||||
--exclude-dir=devnotes
|
||||
)
|
||||
|
||||
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# uninstall-shopdbagent.ps1
|
||||
# ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on
|
||||
# the endpoint. Reverses everything install-shopdbagent.ps1 and the site agent
|
||||
# generator's install.ps1 create:
|
||||
# the endpoint. Reverses everything the site agent generator's install.ps1
|
||||
# creates:
|
||||
# - stops and deletes the Windows service
|
||||
# - kills any running agent/tray processes
|
||||
# - removes the tray logon Run key (64-bit and 32-bit views)
|
||||
# - unregisters the ShopDB user-context probe scheduled task
|
||||
# - removes the credential hive HKLM\SOFTWARE\GE\ShopDB (both views)
|
||||
# - removes the Defender path exclusion for the install directory
|
||||
# - deletes the install directory and the ProgramData data/log tree
|
||||
@@ -20,6 +21,10 @@ param(
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
|
||||
# Scheduled task that runs the user-context probe in the operator's session.
|
||||
# Must match the installer's -UserContextTaskName.
|
||||
[string] $UserContextTaskName = "ShopDB user-context probe",
|
||||
|
||||
# Also remove any server TLS certificate the site-agent installer imported
|
||||
# into the machine Trusted Root store. Off by default because a root cert is
|
||||
# shared trust material: only pull it when you are sure no other service on
|
||||
@@ -113,6 +118,34 @@ Start-Sleep -Seconds 1
|
||||
# 3. Remove the tray logon Run key entry (both views) and the credential hive.
|
||||
Write-Host "[3/6] Removing registry entries ..."
|
||||
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
|
||||
|
||||
# Unregister the probe task. Left behind it would keep firing at every logon
|
||||
# against a deleted exe, which shows up as a recurring task-failure history on a
|
||||
# machine nobody believes has the agent on it any more.
|
||||
try {
|
||||
if (Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $UserContextTaskName -Confirm:$false -ErrorAction Stop
|
||||
Write-Host " removed scheduled task '$UserContextTaskName'"
|
||||
}
|
||||
else {
|
||||
Write-Host " scheduled task '$UserContextTaskName' not present."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not remove scheduled task '$UserContextTaskName': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# The install's one-shot tray launcher is transient and normally removes itself,
|
||||
# but an install that failed partway can leave it behind.
|
||||
try {
|
||||
if (Get-ScheduledTask -TaskName "ShopDB tray first launch" -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName "ShopDB tray first launch" -Confirm:$false -ErrorAction Stop
|
||||
Write-Host " removed leftover scheduled task 'ShopDB tray first launch'"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not remove scheduled task 'ShopDB tray first launch': $($_.Exception.Message)"
|
||||
}
|
||||
Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
|
||||
|
||||
# Remove the All-Users Start Menu shortcut the site-agent installer created.
|
||||
|
||||
@@ -110,7 +110,12 @@ from .plugins import plugin_manager
|
||||
# internals. The journal records the primitive mutations a bulk import makes
|
||||
# (update a cell, insert a row) tied to a batch id, and a generic undo replays
|
||||
# them in reverse. Additive names on the surface, minor bump.
|
||||
__contract_version__ = '0.30.0'
|
||||
#
|
||||
# 0.31.0 widens the asset-panel vocabulary a plugin may declare: two more
|
||||
# tab groups (serviceprocess, reports), a datetime column format, a map body
|
||||
# key, and a tabs section that renders as a table. All optional, so a plugin
|
||||
# that declares none of them is unaffected. Additive, minor bump.
|
||||
__contract_version__ = '0.31.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
@@ -204,6 +209,22 @@ def create_app(config_name: str = None) -> Flask:
|
||||
# Register core blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Finish any restore that is waiting on its schema upgrade. The backup
|
||||
# sidecar has no Flask, so it loads the dump and leaves a sentinel; this
|
||||
# container owns the app context that can run the migrations. Started here
|
||||
# rather than left to a browser poll, because a restore must complete
|
||||
# whether or not anyone is still watching it.
|
||||
#
|
||||
# Skipped unless a backups store actually exists, which keeps it off every
|
||||
# deployment that does not run the backup overlay - and out of the test
|
||||
# suite, which builds this app thousands of times.
|
||||
if not app.config.get('TESTING'):
|
||||
from .core.api.shopdbbackups import (
|
||||
_backups_configured, start_restore_finalizer)
|
||||
with app.app_context():
|
||||
if _backups_configured():
|
||||
start_restore_finalizer(app)
|
||||
|
||||
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
|
||||
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
||||
from .utils.apitoken_auth import install_apitoken_auth
|
||||
@@ -298,6 +319,7 @@ CORE_BLUEPRINT_NAMES = (
|
||||
'pluginui',
|
||||
'apitokens',
|
||||
'docs',
|
||||
'shopdbbackups',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
|
||||
# the whole batch can be undone. ImportBatch is the identity approval, write, and
|
||||
# undo all reference; ImportJournalEntry is one reversible mutation. Exposed so a
|
||||
# plugin's import UI can list applied batches and reverse one without reaching
|
||||
# into core internals. See docs/PLUGIN-HOOKS.md.
|
||||
# into core internals. See the PLUGIN-HOOKS wiki page.
|
||||
from shopdb.core.models import (
|
||||
ImportBatch,
|
||||
ImportJournalEntry,
|
||||
|
||||
@@ -163,11 +163,14 @@ class Config:
|
||||
# three different routes, which makes it a property of the app rather than
|
||||
# of any handler.
|
||||
#
|
||||
# 128 MB clears the largest thing anyone legitimately sends - the agent
|
||||
# binary upload caps itself at 64 MB - with room for the multipart envelope.
|
||||
# 256 MB clears the largest thing anyone legitimately sends - the agent
|
||||
# binary upload caps itself at 128 MB - with room for the multipart
|
||||
# envelope. This must stay STRICTLY ABOVE the agent cap: it bounds the
|
||||
# whole request (file + boundaries + form fields), so setting the two
|
||||
# equal would reject a file sitting exactly at the agent limit.
|
||||
# Over this, Werkzeug answers 413 without reading the body.
|
||||
MAX_CONTENT_LENGTH = int(
|
||||
os.environ.get('MAX_CONTENT_LENGTH', 128 * 1024 * 1024))
|
||||
os.environ.get('MAX_CONTENT_LENGTH', 256 * 1024 * 1024))
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
|
||||
@@ -27,6 +27,7 @@ from .setup import setup_bp
|
||||
from .pluginui import pluginui_bp
|
||||
from .apitokens import apitokens_bp
|
||||
from .docs import docs_bp
|
||||
from .shopdbbackups import shopdbbackups_bp
|
||||
|
||||
__all__ = [
|
||||
'auth_bp',
|
||||
@@ -56,4 +57,5 @@ __all__ = [
|
||||
'pluginui_bp',
|
||||
'apitokens_bp',
|
||||
'docs_bp',
|
||||
'shopdbbackups_bp',
|
||||
]
|
||||
|
||||
@@ -25,7 +25,14 @@ pluginui_bp = Blueprint('pluginui', __name__)
|
||||
#
|
||||
# 'details' is first and is the fallback: it holds what an asset IS, which is
|
||||
# what a panel declaring nothing almost always contributes.
|
||||
ASSET_PANEL_GROUPS = ('details', 'monitors', 'software', 'activity')
|
||||
#
|
||||
# This tuple is a THIRD copy of the tab vocabulary - RENDERED_GROUPS in
|
||||
# PluginAssetPanels.vue and the tab list in PCDetail.vue are the other two.
|
||||
# Adding a group means touching all three: a value missing here is rewritten to
|
||||
# 'details' before the client ever sees it, so the panels quietly render in the
|
||||
# wrong tab and the intended tab never appears. No error, no log line.
|
||||
ASSET_PANEL_GROUPS = ('details', 'monitors', 'serviceprocess', 'reports',
|
||||
'software', 'activity')
|
||||
|
||||
|
||||
def _collect(hookname):
|
||||
|
||||
@@ -1320,6 +1320,21 @@ def build_default_settings():
|
||||
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
|
||||
)
|
||||
},
|
||||
{
|
||||
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Same
|
||||
# reasoning as entra_device_url - the LAPS blade lives in the same
|
||||
# tenant-specific portal, so the host cannot be a product fact.
|
||||
'key': 'laps_device_url',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'integrations',
|
||||
'description': (
|
||||
'Azure LAPS (local admin password) URL template. {objectid} is '
|
||||
'the device object id. Example (US Gov): https://portal.azure.us/'
|
||||
'?feature.msaljs=true#view/Microsoft_AAD_Devices/'
|
||||
'DeviceDetailsMenuBlade/~/LocalAdminPassword/objectId/{objectid}'
|
||||
)
|
||||
},
|
||||
{
|
||||
'key': 'servicenow_ticket_prefixes',
|
||||
'value': 'GEINC,GECHG,GERIT,GESCT',
|
||||
@@ -1488,6 +1503,99 @@ def build_default_settings():
|
||||
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
||||
'or json (generic {title,text})'
|
||||
},
|
||||
# Backup destination settings
|
||||
#
|
||||
# These are the ground wiring for where the one-shot backup container
|
||||
# ships each set. 'local' keeps sets on the mounted backups volume and
|
||||
# needs no credentials. 'smb' and 'aws' are declared so the UI and the
|
||||
# backup runner can be configured now; the client libraries (an SMB
|
||||
# stack, boto3/awscli) are deliberately NOT pulled in yet - those land
|
||||
# behind the security review that vets third-party deps against
|
||||
# external exploit. Until then a non-local destination is accepted and
|
||||
# persisted but the runner treats it as not-yet-armed.
|
||||
#
|
||||
# Secrets follow the existing convention: any key containing
|
||||
# 'password'/'secret'/'token' is masked by _is_secret on the way out
|
||||
# and preserved when the client submits the mask back unchanged.
|
||||
{
|
||||
'key': 'backup_destination_kind',
|
||||
'value': 'local',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'Where backup sets are shipped: local, smb, or aws. '
|
||||
'Non-local destinations are wiring-only until the '
|
||||
'security review clears their client libraries.'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_host',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB/CIFS server hostname or IP for the backup share'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_share',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB share name (the top-level export, no path)'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_path',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'Subpath within the SMB share to write sets into'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_username',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB authentication username (DOMAIN\\user allowed)'
|
||||
},
|
||||
{
|
||||
'key': 'backup_smb_password',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'SMB authentication password'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_region',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'AWS region for the S3 backup bucket (e.g. us-east-1)'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_bucket',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'S3 bucket name that holds backup sets'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_prefix',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'Optional key prefix within the S3 bucket'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_accesskeyid',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'AWS access key id for the backup bucket'
|
||||
},
|
||||
{
|
||||
'key': 'backup_aws_secretaccesskey',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'backup',
|
||||
'description': 'AWS secret access key for the backup bucket'
|
||||
},
|
||||
# Audit log retention lives at 'audit_retentiondays' (category site),
|
||||
# defined above and read by shopdb/core/services/auditretention.py. A
|
||||
# second key 'audit_retention_days' was seeded here with a different
|
||||
|
||||
799
shopdb/core/api/shopdbbackups.py
Normal file
799
shopdb/core/api/shopdbbackups.py
Normal file
@@ -0,0 +1,799 @@
|
||||
"""Backups API routes.
|
||||
|
||||
Admin-only surface over the site's backup sets. The heavy lifting (mysqldump,
|
||||
restic snapshot of the instance tree, retention prune, integrity check) runs in
|
||||
the one-shot backup container defined in docker-compose.backup.yml; this
|
||||
blueprint is the read-mostly control plane the UI talks to.
|
||||
|
||||
Two recovery products stay firewalled, per the design notes:
|
||||
- code rollback lives in git (tags/releases), NOT here
|
||||
- data restore lives here, gated on schema compatibility via each set's manifest
|
||||
|
||||
Every backup set carries a manifest.json stamped with the core + plugin Alembic
|
||||
revisions and the running image tag at capture time. A restore is a CHECKED
|
||||
operation: current code must be at least the set's schema revision, so we never
|
||||
load rows that reference a column the live schema has not applied yet.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
|
||||
from shopdb.core.models import AuditLog
|
||||
from shopdb.core.models.setting import Setting
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_role
|
||||
|
||||
shopdbbackups_bp = Blueprint('shopdbbackups', __name__)
|
||||
|
||||
# Supported backup destination kinds. 'local' needs no credentials; 'smb' and
|
||||
# 'aws' are ground-wired here so operators can configure them now, but their
|
||||
# client libraries are held back until the security review clears them, so the
|
||||
# runner treats a non-local destination as configured-but-not-yet-armed.
|
||||
DESTINATION_KINDS = ('local', 'smb', 'aws')
|
||||
|
||||
# The setting keys that make up each destination's config, grouped by kind so
|
||||
# the GET can surface only the relevant fields and the PUT can validate what it
|
||||
# is allowed to write. Secret keys (password/secret/token in the name) are
|
||||
# masked by the shared settings serializer, same convention as SMTP/SAML.
|
||||
DESTINATION_FIELDS = {
|
||||
'local': [],
|
||||
'smb': [
|
||||
'backup_smb_host',
|
||||
'backup_smb_share',
|
||||
'backup_smb_path',
|
||||
'backup_smb_username',
|
||||
'backup_smb_password',
|
||||
],
|
||||
'aws': [
|
||||
'backup_aws_region',
|
||||
'backup_aws_bucket',
|
||||
'backup_aws_prefix',
|
||||
'backup_aws_accesskeyid',
|
||||
'backup_aws_secretaccesskey',
|
||||
],
|
||||
}
|
||||
|
||||
# Keys whose value must never leave the API in the clear. Mirrors the shared
|
||||
# settings convention (_is_secret) so the two surfaces agree on what is secret.
|
||||
_SECRET_TOKENS = ('password', 'secret', 'token')
|
||||
SECRET_MASK = '********'
|
||||
|
||||
|
||||
def _is_secret_key(key):
|
||||
return any(token in key for token in _SECRET_TOKENS)
|
||||
|
||||
# Where the backup container drops each set and its manifest. Mounted read-only
|
||||
# for the api container so the UI can enumerate sets without being able to
|
||||
# mutate them; writes happen only from the backup one-shot.
|
||||
BACKUPS_ROOT_ENV = 'SHOPDB_BACKUPS_ROOT'
|
||||
DEFAULT_BACKUPS_ROOT = '/backups'
|
||||
|
||||
# Retention the backup container enforces; surfaced read-only so the UI can show
|
||||
# "day 1 of 5 retained" without re-deriving the policy.
|
||||
RETENTION = {
|
||||
'keepdaily': 5,
|
||||
'keepweekly': 4,
|
||||
'keepmonthly': 6,
|
||||
}
|
||||
|
||||
|
||||
def _backups_root():
|
||||
return os.environ.get(BACKUPS_ROOT_ENV, DEFAULT_BACKUPS_ROOT)
|
||||
|
||||
|
||||
def _backups_configured():
|
||||
"""Whether this deployment has a backup store behind the feature.
|
||||
|
||||
The overlay is a compose profile: a third container writes the sets onto a
|
||||
shared volume, and the api container only ever reads them and drops a
|
||||
sentinel. A Windows/IIS install has no such container, and the default root
|
||||
resolves there to a drive-letter path nothing creates - so every button
|
||||
either 500s on PermissionError or reports 'queued' for a run that will
|
||||
never happen. Answer the caller honestly instead.
|
||||
"""
|
||||
return os.path.isdir(_backups_root())
|
||||
|
||||
|
||||
def _unavailable_response():
|
||||
# 404 + NOT_FOUND is the established shape for "this feature is not
|
||||
# enabled here" (the configuration-policy routes answer the same way), and
|
||||
# the UI already tells a 404 apart from a real failure.
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'Site Backups is not configured on this deployment. It needs the '
|
||||
'backup overlay and a readable backups root (%s).' % BACKUPS_ROOT_ENV,
|
||||
http_code=404)
|
||||
|
||||
|
||||
def _read_json(path):
|
||||
"""Load a JSON file, or None when it is absent or unreadable."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _read_status(kind):
|
||||
"""Return the live status dict for a run kind, or None when idle."""
|
||||
root = _backups_root()
|
||||
return _read_json(os.path.join(root, '.' + kind + '-status.json'))
|
||||
|
||||
|
||||
def _read_history(kind):
|
||||
"""Return the recorded run history for a kind, newest first."""
|
||||
root = _backups_root()
|
||||
entries = _read_json(os.path.join(root, 'history', kind + 's.json'))
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return list(reversed(entries))
|
||||
|
||||
|
||||
def _write_status(kind, phase, setname, message):
|
||||
"""Seed a live status file so the UI shows movement the instant a run is
|
||||
queued, before the watcher has picked up the sentinel."""
|
||||
root = _backups_root()
|
||||
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
|
||||
payload = {
|
||||
'kind': kind,
|
||||
'phase': phase,
|
||||
'setname': setname,
|
||||
'message': message,
|
||||
'updatedat': stamp,
|
||||
}
|
||||
try:
|
||||
path = os.path.join(root, '.' + kind + '-status.json')
|
||||
with open(path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(payload, handle)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _append_history(kind, outcome, setname, message):
|
||||
"""Append one run record to the shared history the UI reads, newest last.
|
||||
|
||||
Mirrors status.sh's history_append so a finalize that completes in the api
|
||||
(not the sidecar) still lands in the same history file the restore page
|
||||
shows. Best-effort: history is a convenience log, never a gate.
|
||||
"""
|
||||
root = _backups_root()
|
||||
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
|
||||
entry = {
|
||||
'kind': kind,
|
||||
'outcome': outcome,
|
||||
'setname': setname,
|
||||
'message': message,
|
||||
'at': stamp,
|
||||
}
|
||||
path = os.path.join(root, 'history', kind + 's.json')
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
entries = _read_json(path)
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
entries.append(entry)
|
||||
with open(path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(entries, handle)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# The post-restore upgrade is claimed by renaming the sentinel. rename(2) is
|
||||
# atomic, so exactly one claimant wins even with several gunicorn workers and a
|
||||
# browser poll all racing - which matters because the loser would otherwise run
|
||||
# Alembic concurrently against the same database.
|
||||
UPGRADE_SENTINEL = '.upgrade-requested'
|
||||
UPGRADE_CLAIM = '.upgrade-inprogress'
|
||||
UPGRADE_FAILED = '.upgrade-failed'
|
||||
|
||||
|
||||
def _claim_upgrade():
|
||||
"""Take exclusive ownership of a pending upgrade, or return None.
|
||||
|
||||
Returns the set name on success. Losing the race is not an error: it means
|
||||
another worker is already finalizing the same restore.
|
||||
"""
|
||||
root = _backups_root()
|
||||
sentinel = os.path.join(root, UPGRADE_SENTINEL)
|
||||
claim = os.path.join(root, UPGRADE_CLAIM)
|
||||
try:
|
||||
os.rename(sentinel, claim)
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with open(claim, 'r', encoding='utf-8') as handle:
|
||||
return handle.read().strip() or 'unknown'
|
||||
except OSError:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _finalize_claimed(setname):
|
||||
"""Run the upgrade for a claimed restore and record how it ended.
|
||||
|
||||
A failure parks the claim as .upgrade-failed rather than putting it back:
|
||||
re-arming it here would retry a migration that just failed, every poll,
|
||||
forever. The operator re-arms it explicitly through the finalize endpoint.
|
||||
"""
|
||||
root = _backups_root()
|
||||
claim = os.path.join(root, UPGRADE_CLAIM)
|
||||
_write_status('restore', 'upgrading', setname,
|
||||
'Upgrading restored schema to code')
|
||||
try:
|
||||
_run_schema_upgrade()
|
||||
except Exception as error: # noqa: BLE001 - report any upgrade failure
|
||||
message = 'Schema upgrade failed: ' + str(error)
|
||||
try:
|
||||
os.replace(claim, os.path.join(root, UPGRADE_FAILED))
|
||||
except OSError:
|
||||
pass
|
||||
_write_status('restore', 'failed', setname, message)
|
||||
_append_history('restore', 'failed', setname, message)
|
||||
current_app.logger.error('Post-restore schema upgrade failed: %s', error)
|
||||
return False
|
||||
try:
|
||||
os.remove(claim)
|
||||
except OSError:
|
||||
pass
|
||||
_write_status('restore', 'done', setname, 'Restore complete')
|
||||
_append_history('restore', 'success', setname, 'Restore complete')
|
||||
current_app.logger.info('Restore finalized: %s', setname)
|
||||
return True
|
||||
|
||||
|
||||
def start_restore_finalizer(app):
|
||||
"""Watch for a restore awaiting its schema upgrade, and run it.
|
||||
|
||||
WHY THIS EXISTS: the restore itself runs in the Flask-less backup sidecar,
|
||||
which loads the dump and drops a sentinel because it cannot run
|
||||
'flask db upgrade'. Until this watcher, the ONLY thing that acted on that
|
||||
sentinel was the browser poll loop of the modal that queued the restore -
|
||||
so closing the tab, or letting a token expire, during the minutes a load
|
||||
takes left the data at the old revision under new code, with every query on
|
||||
a newer column failing and nothing retrying.
|
||||
|
||||
Runs in the api container because that is where the app context and the
|
||||
engine live, and out of band rather than in a request because Alembic can
|
||||
outrun the gunicorn worker timeout and get killed mid-chain.
|
||||
"""
|
||||
interval = max(1, int(os.environ.get('SHOPDB_FINALIZE_POLL_SECONDS', '10')))
|
||||
|
||||
def _loop():
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
try:
|
||||
with app.app_context():
|
||||
if not _backups_configured():
|
||||
continue
|
||||
setname = _claim_upgrade()
|
||||
if setname:
|
||||
_finalize_claimed(setname)
|
||||
except Exception: # noqa: BLE001 - a watcher must not die
|
||||
try:
|
||||
app.logger.exception('Restore finalizer pass failed')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
thread = threading.Thread(target=_loop, name='restore-finalizer', daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def _run_schema_upgrade():
|
||||
"""Bring the just-restored database up to the running code, in-process.
|
||||
|
||||
Runs the same two idempotent steps every deploy runs: the core Alembic
|
||||
upgrade to head, then each plugin chain via the plugin manager. Both are
|
||||
no-ops when the data already matches the code head, so this is safe to call
|
||||
even when the restored dump was already current. Uses the live app context
|
||||
and its configured engine, so no subprocess or extra Flask CLI is needed.
|
||||
"""
|
||||
from flask_migrate import upgrade as alembic_upgrade
|
||||
|
||||
alembic_upgrade()
|
||||
|
||||
manager = current_app.extensions.get('plugin_manager')
|
||||
if manager is None:
|
||||
raise RuntimeError('Plugin manager not initialized; cannot upgrade plugins')
|
||||
results = manager.upgrade_all_plugins()
|
||||
failed = {name: status for name, status in (results or {}).items()
|
||||
if status not in ('ok', 'no-migrations')}
|
||||
if failed:
|
||||
raise RuntimeError('Plugin upgrade failed: ' + json.dumps(failed))
|
||||
|
||||
|
||||
|
||||
def _read_manifest(setdir):
|
||||
"""Load a set's manifest.json, or None when absent/unreadable.
|
||||
|
||||
A set without a readable manifest is not restorable (we cannot check its
|
||||
schema), so callers treat None as "unverified" rather than trusting it.
|
||||
"""
|
||||
path = os.path.join(setdir, 'manifest.json')
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _current_schema_revisions():
|
||||
"""Return the live Alembic head revisions (core + plugins) for this build.
|
||||
|
||||
Compared against a set's stamped revisions to decide restore compatibility.
|
||||
Imported lazily so a missing alembic at request time degrades to an empty
|
||||
map (everything reads as "unknown") instead of 500ing the list endpoint.
|
||||
"""
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
root = current_app.root_path
|
||||
migrations = os.path.join(os.path.dirname(root), 'migrations')
|
||||
ini = os.path.join(migrations, 'alembic.ini')
|
||||
if not os.path.isfile(ini):
|
||||
return {}
|
||||
try:
|
||||
config = Config(ini)
|
||||
# This is a Flask-Migrate project: alembic.ini carries no
|
||||
# script_location (it is set at runtime), so ScriptDirectory.from_config
|
||||
# would raise and leave every set reading as incompatible. Point it at
|
||||
# the migrations directory - the same versions tree manifest.py scans.
|
||||
config.set_main_option('script_location', migrations)
|
||||
script = ScriptDirectory.from_config(config)
|
||||
return {'core': list(script.get_heads())}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _known_core_revisions():
|
||||
"""Return every core Alembic revision this build knows, heads and ancestors.
|
||||
|
||||
Restore compatibility is now data-driven: a set restores when the running
|
||||
code KNOWS the dump's data revision (so `flask db upgrade` can roll it
|
||||
forward to head). Heads alone cannot answer that - a dump stamped at an
|
||||
older-but-valid revision is perfectly restorable - so we walk the whole
|
||||
script history. Lazy import + broad except keeps a missing/broken alembic
|
||||
from 500ing the list endpoint (returns empty = everything reads unknown).
|
||||
"""
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
except ImportError:
|
||||
return set()
|
||||
|
||||
root = current_app.root_path
|
||||
migrations = os.path.join(os.path.dirname(root), 'migrations')
|
||||
ini = os.path.join(migrations, 'alembic.ini')
|
||||
if not os.path.isfile(ini):
|
||||
return set()
|
||||
try:
|
||||
config = Config(ini)
|
||||
config.set_main_option('script_location', migrations)
|
||||
script = ScriptDirectory.from_config(config)
|
||||
return {rev.revision for rev in script.walk_revisions()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _known_plugin_revisions():
|
||||
"""Every revision each plugin chain knows, as {plugin: {revisions}}.
|
||||
|
||||
Read by text scan rather than through Alembic. Each plugin chain has its own
|
||||
env and version table, so loading them properly would mean building 17
|
||||
ScriptDirectories on a request that only needs to answer "is this id in
|
||||
there" - and manifest.py already reads the same trees the same way, so the
|
||||
two agree by construction.
|
||||
"""
|
||||
root = os.path.dirname(current_app.root_path)
|
||||
plugins_dir = os.path.join(root, 'plugins')
|
||||
known = {}
|
||||
if not os.path.isdir(plugins_dir):
|
||||
return known
|
||||
for name in sorted(os.listdir(plugins_dir)):
|
||||
versions = os.path.join(plugins_dir, name, 'migrations', 'versions')
|
||||
if not os.path.isdir(versions):
|
||||
continue
|
||||
revisions = set()
|
||||
for filename in os.listdir(versions):
|
||||
if not filename.endswith('.py'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(versions, filename), 'r',
|
||||
encoding='utf-8') as handle:
|
||||
for line in handle:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('revision ='):
|
||||
revisions.add(
|
||||
stripped.split('=', 1)[1].strip().strip('"\''))
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
if revisions:
|
||||
known[name] = revisions
|
||||
return known
|
||||
|
||||
|
||||
def _plugin_data_is_known(manifest):
|
||||
"""Whether this build knows every plugin revision the set's data sits at.
|
||||
|
||||
A set with no `dataplugins` predates the stamp; its plugin revisions are
|
||||
unknowable, so it keeps the old core-only verdict rather than being marked
|
||||
unrestorable retroactively.
|
||||
"""
|
||||
stamped = manifest.get('dataplugins') or {}
|
||||
if not isinstance(stamped, dict) or not stamped:
|
||||
return True
|
||||
known = _known_plugin_revisions()
|
||||
for name, revision in stamped.items():
|
||||
if not revision:
|
||||
continue
|
||||
if revision not in known.get(name, ()):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_compatible(manifest, current):
|
||||
"""Decide whether a set restores onto THIS build.
|
||||
|
||||
Data-driven gate (the fix for the FBJXBDB4 outage): the authoritative
|
||||
signal is the set's `datarev` - the actual `alembic_version` of the dumped
|
||||
DATABASE, not the code head that captured it. A restore is safe when the
|
||||
running code KNOWS that revision (it is somewhere in this build's migration
|
||||
history), because then the post-restore finalize can run `flask db upgrade`
|
||||
to roll the loaded data forward to head. If the code does not know the
|
||||
revision, the dump is from a NEWER schema than this build and would 500 on
|
||||
restored rows, so we block until the code is upgraded.
|
||||
|
||||
The same question is asked of every PLUGIN chain, because plugin schemas
|
||||
move independently: code rolled back to an older plugin revision can sit at
|
||||
an identical core head while the dump carries plugin tables it cannot
|
||||
migrate.
|
||||
|
||||
Legacy sets predate `datarev`; fall back to the old head-subset check on
|
||||
`schemarev` so they stay listable and restorable as before.
|
||||
"""
|
||||
if not manifest:
|
||||
return False
|
||||
|
||||
# A manifest that CARRIES the key but left it empty is not a legacy set: it
|
||||
# is a set whose data revision could not be read when it was taken. Falling
|
||||
# through to the code-to-code check would badge it restorable on no
|
||||
# evidence, using exactly the comparison the datarev work replaced. Only a
|
||||
# manifest with no datarev key at all predates the stamp.
|
||||
if 'datarev' in manifest and not manifest.get('datarev'):
|
||||
return False
|
||||
|
||||
datarev = manifest.get('datarev')
|
||||
if datarev:
|
||||
known = _known_core_revisions()
|
||||
if not known:
|
||||
return False
|
||||
# Core AND every plugin chain. Core alone said yes to a set whose plugin
|
||||
# data the code had never heard of, which loads and then dies during the
|
||||
# plugin upgrade with the old data already gone.
|
||||
return datarev in known and _plugin_data_is_known(manifest)
|
||||
setrevs = set(manifest.get('schemarev', {}).get('core', []))
|
||||
currevs = set(current.get('core', []))
|
||||
if not setrevs or not currevs:
|
||||
return False
|
||||
# Compatible when the current heads cover every head the set was taken at.
|
||||
return setrevs.issubset(currevs)
|
||||
|
||||
|
||||
# Reserved entries under the backups root that are not backup sets: the run
|
||||
# history store and any dotfile sentinels/status files the watcher drops.
|
||||
_NON_SET_ENTRIES = ('history',)
|
||||
|
||||
|
||||
def _list_sets():
|
||||
"""Enumerate backup sets newest-first, each summarized for the list view."""
|
||||
root = _backups_root()
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
current = _current_schema_revisions()
|
||||
sets = []
|
||||
for name in sorted(os.listdir(root), reverse=True):
|
||||
if name in _NON_SET_ENTRIES or name.startswith('.'):
|
||||
continue
|
||||
setdir = os.path.join(root, name)
|
||||
if not os.path.isdir(setdir):
|
||||
continue
|
||||
manifest = _read_manifest(setdir)
|
||||
dbdump = os.path.join(setdir, 'db.sql.gz')
|
||||
sizebytes = 0
|
||||
for base, _dirs, files in os.walk(setdir):
|
||||
for filename in files:
|
||||
try:
|
||||
sizebytes += os.path.getsize(os.path.join(base, filename))
|
||||
except OSError:
|
||||
pass
|
||||
sets.append({
|
||||
'snapshotid': name,
|
||||
'takenat': (manifest or {}).get('takenat'),
|
||||
'sizebytes': sizebytes,
|
||||
'schemarev': (manifest or {}).get('schemarev', {}),
|
||||
'imagetag': (manifest or {}).get('imagetag'),
|
||||
'ischeckpassed': bool((manifest or {}).get('ischeckpassed')),
|
||||
'hasmanifest': manifest is not None,
|
||||
'hasdbdump': os.path.isfile(dbdump),
|
||||
'iscompatible': _is_compatible(manifest, current),
|
||||
})
|
||||
return sets
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def list_backups():
|
||||
"""List every retained backup set, newest first, with a compatibility flag."""
|
||||
sets = _list_sets()
|
||||
laststgood = next((s for s in sets if s['ischeckpassed']), None)
|
||||
meta = {
|
||||
'retention': RETENTION,
|
||||
'lastgoodat': (laststgood or {}).get('takenat'),
|
||||
'count': len(sets),
|
||||
'configured': _backups_configured(),
|
||||
}
|
||||
return success_response(data=sets, meta=meta)
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/<snapshotid>', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def get_backup(snapshotid):
|
||||
"""Return one set's full manifest plus a resolved compatibility verdict."""
|
||||
root = _backups_root()
|
||||
setdir = os.path.join(root, os.path.basename(snapshotid))
|
||||
if not os.path.isdir(setdir):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
|
||||
http_code=404)
|
||||
manifest = _read_manifest(setdir)
|
||||
current = _current_schema_revisions()
|
||||
return success_response(data={
|
||||
'snapshotid': os.path.basename(snapshotid),
|
||||
'manifest': manifest,
|
||||
'currentschemarev': current,
|
||||
'iscompatible': _is_compatible(manifest, current),
|
||||
'hasmanifest': manifest is not None,
|
||||
})
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def trigger_backup():
|
||||
"""Kick off an on-demand backup run.
|
||||
|
||||
The api container cannot run docker; it writes a sentinel the host-side
|
||||
scheduler (or an operator running `docker compose run --rm backup`) picks
|
||||
up. Returning isrunning=false with a clear message keeps the UI honest
|
||||
rather than pretending the run started inside this process.
|
||||
"""
|
||||
if not _backups_configured():
|
||||
return _unavailable_response()
|
||||
|
||||
root = _backups_root()
|
||||
try:
|
||||
os.makedirs(root, exist_ok=True)
|
||||
sentinel = os.path.join(root, '.backup-requested')
|
||||
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
|
||||
with open(sentinel, 'w', encoding='utf-8') as handle:
|
||||
handle.write(stamp)
|
||||
except OSError as error:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Could not queue a backup: ' + str(error),
|
||||
http_code=500)
|
||||
_write_status('backup', 'queued', '', 'Backup queued')
|
||||
AuditLog.log('requested', 'Backup', entityname=stamp)
|
||||
return success_response(
|
||||
data={'isrunning': False, 'isqueued': True, 'requestedat': stamp},
|
||||
message='Backup queued. The backup service runs it on its next pass.')
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/<snapshotid>/restore', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def restore_backup(snapshotid):
|
||||
"""Queue a restore of a set, gated on schema compatibility.
|
||||
|
||||
We refuse outright when the set is incompatible (current code older than the
|
||||
set's schema) so an operator cannot foot-gun a schema mismatch from the UI.
|
||||
The actual restore is performed by scripts/backup/restore.sh in the backup
|
||||
container; here we validate and record intent.
|
||||
"""
|
||||
if not _backups_configured():
|
||||
return _unavailable_response()
|
||||
|
||||
root = _backups_root()
|
||||
setdir = os.path.join(root, os.path.basename(snapshotid))
|
||||
if not os.path.isdir(setdir):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
|
||||
http_code=404)
|
||||
manifest = _read_manifest(setdir)
|
||||
current = _current_schema_revisions()
|
||||
if not _is_compatible(manifest, current):
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'This backup was taken on a newer schema than the running code. '
|
||||
'Upgrade the code to at least the set\'s revision, run '
|
||||
'`flask db upgrade` and `flask plugin upgrade-all`, then retry.',
|
||||
details={'currentschemarev': current,
|
||||
'setschemarev': (manifest or {}).get('schemarev', {})},
|
||||
http_code=409)
|
||||
try:
|
||||
sentinel = os.path.join(root, '.restore-requested')
|
||||
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
|
||||
with open(sentinel, 'w', encoding='utf-8') as handle:
|
||||
handle.write(os.path.basename(snapshotid))
|
||||
except OSError as error:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Could not queue the restore: ' + str(error),
|
||||
http_code=500)
|
||||
_write_status('restore', 'queued', os.path.basename(snapshotid),
|
||||
'Restore queued')
|
||||
AuditLog.log('restore-requested', 'Backup',
|
||||
entityname=os.path.basename(snapshotid))
|
||||
return success_response(
|
||||
data={'isqueued': True, 'snapshotid': os.path.basename(snapshotid)},
|
||||
message='Restore queued and validated against the live schema.')
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/finalize-restore', methods=['POST'])
|
||||
@require_role('admin')
|
||||
def finalize_restore():
|
||||
"""Make sure a restore awaiting its schema upgrade is queued to be finalized.
|
||||
|
||||
This used to RUN the upgrade inline, which made two things true that should
|
||||
not have been: a restore only completed while a browser tab stayed open on
|
||||
it, and the upgrade ran inside a request, where Alembic can outrun the
|
||||
gunicorn worker timeout and be killed part-way through the chain, leaving a
|
||||
half-applied revision the next attempt trips over.
|
||||
|
||||
The work now belongs to the finalizer watcher in this container
|
||||
(start_restore_finalizer). This endpoint only ensures something is pending
|
||||
for it to claim, so it is safe to call repeatedly, and it is how an operator
|
||||
retries an upgrade that failed.
|
||||
"""
|
||||
if not _backups_configured():
|
||||
return _unavailable_response()
|
||||
|
||||
root = _backups_root()
|
||||
sentinel = os.path.join(root, UPGRADE_SENTINEL)
|
||||
claim = os.path.join(root, UPGRADE_CLAIM)
|
||||
failed = os.path.join(root, UPGRADE_FAILED)
|
||||
|
||||
if os.path.exists(claim):
|
||||
return success_response(
|
||||
data={'queued': True, 'inprogress': True},
|
||||
message='The schema upgrade is already running.')
|
||||
|
||||
if os.path.exists(sentinel):
|
||||
return success_response(
|
||||
data={'queued': True, 'inprogress': False},
|
||||
message='The schema upgrade is queued and will run shortly.')
|
||||
|
||||
# A previous attempt failed and parked itself. Re-arming is the retry.
|
||||
if os.path.exists(failed):
|
||||
try:
|
||||
os.replace(failed, sentinel)
|
||||
except OSError as error:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Could not re-queue the schema upgrade: ' + str(error),
|
||||
http_code=500)
|
||||
AuditLog.log('restore-finalize-retried', 'Backup')
|
||||
return success_response(
|
||||
data={'queued': True, 'inprogress': False, 'retried': True},
|
||||
message='The failed schema upgrade has been queued to run again.')
|
||||
|
||||
return success_response(
|
||||
data={'queued': False},
|
||||
message='No restore is awaiting schema upgrade.')
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/status', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def get_status():
|
||||
"""Return the live status of the most recent backup and restore runs.
|
||||
|
||||
The watcher writes these files as it moves through each phase; the UI polls
|
||||
this endpoint to drive the restore progress spinner and phase label. Either
|
||||
value is None when that kind of run has never been requested.
|
||||
"""
|
||||
return success_response(data={
|
||||
'configured': _backups_configured(),
|
||||
'backup': _read_status('backup'),
|
||||
'restore': _read_status('restore'),
|
||||
})
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/history', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def get_history():
|
||||
"""Return the recorded history of backup and restore runs, newest first.
|
||||
|
||||
These are operational run logs written by the backup container onto the
|
||||
shared volume, not the audit trail (AuditLog records the requested intent).
|
||||
"""
|
||||
return success_response(data={
|
||||
'backups': _read_history('backup'),
|
||||
'restores': _read_history('restore'),
|
||||
})
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/settings', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def get_backup_settings():
|
||||
"""Return destination + retention config for the settings pane.
|
||||
|
||||
The destination kind and its (non-secret) fields come from the settings
|
||||
store so an operator can configure where backups ship without editing env
|
||||
or redeploying. Secret fields (SMB password, AWS secret key) are never
|
||||
returned in the clear: we send a mask when a value is set and an empty
|
||||
string when it is not, matching the shared settings API convention.
|
||||
|
||||
'smb' and 'aws' are configurable now but their client libraries are held
|
||||
back pending the security review, so we flag those destinations as armed:
|
||||
false to tell the UI the config is stored but not yet acting.
|
||||
"""
|
||||
kind = Setting.get('backup_destination_kind', 'local')
|
||||
if kind not in DESTINATION_KINDS:
|
||||
kind = 'local'
|
||||
|
||||
fields = {}
|
||||
for key in DESTINATION_FIELDS.get(kind, []):
|
||||
raw = Setting.get(key, '')
|
||||
if _is_secret_key(key):
|
||||
fields[key] = SECRET_MASK if raw else ''
|
||||
else:
|
||||
fields[key] = raw or ''
|
||||
|
||||
return success_response(data={
|
||||
'destinationkind': kind,
|
||||
'destinationkinds': list(DESTINATION_KINDS),
|
||||
'fields': fields,
|
||||
'isarmed': kind == 'local',
|
||||
'retention': RETENTION,
|
||||
'backupsroot': _backups_root(),
|
||||
})
|
||||
|
||||
|
||||
@shopdbbackups_bp.route('/settings', methods=['PUT'])
|
||||
@require_role('admin')
|
||||
def update_backup_settings():
|
||||
"""Persist the backup destination kind and its credential/config fields.
|
||||
|
||||
Only keys that belong to the chosen destination are accepted, so a stray
|
||||
payload cannot write arbitrary settings rows through this surface. A secret
|
||||
field submitted as the mask placeholder means "leave unchanged" - the
|
||||
client only ever received the mask, so we never overwrite a real secret
|
||||
with it.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
kind = data.get('destinationkind', Setting.get('backup_destination_kind', 'local'))
|
||||
if kind not in DESTINATION_KINDS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'destinationkind must be one of: ' + ', '.join(DESTINATION_KINDS))
|
||||
|
||||
Setting.set('backup_destination_kind', kind, valuetype='string',
|
||||
category='backup')
|
||||
|
||||
incoming = data.get('fields', {}) or {}
|
||||
allowed = set(DESTINATION_FIELDS.get(kind, []))
|
||||
for key, value in incoming.items():
|
||||
if key not in allowed:
|
||||
continue
|
||||
if _is_secret_key(key) and value == SECRET_MASK:
|
||||
continue
|
||||
Setting.set(key, value if value is not None else '',
|
||||
valuetype='string', category='backup')
|
||||
|
||||
AuditLog.log('updated', 'BackupSettings', entityname=kind, changes={
|
||||
'destinationkind': kind,
|
||||
})
|
||||
|
||||
return get_backup_settings()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Every /api/ error answers in the documented envelope.
|
||||
|
||||
docs/API-REFERENCE.md says an error is
|
||||
the API-REFERENCE wiki page says an error is
|
||||
{"status":"error","data":{"error":{"code","message"}},"meta":{...}}. Three
|
||||
statuses came straight from Werkzeug as HTML instead:
|
||||
|
||||
|
||||
44
tests/test_core/test_pluginui_panel_groups.py
Normal file
44
tests/test_core/test_pluginui_panel_groups.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""The server-side panel group vocabulary must admit every group a plugin uses.
|
||||
|
||||
`asset_panels()` resolves a panel's group BEFORE the client sees it, coercing
|
||||
any unrecognised value to 'details'. That makes ASSET_PANEL_GROUPS a third copy
|
||||
of the tab vocabulary, alongside RENDERED_GROUPS in PluginAssetPanels.vue and
|
||||
the tab list in PCDetail.vue. Phase 3 added 'serviceprocess' to the two
|
||||
frontend copies and missed this one, so both Service/Process panels were
|
||||
silently relabelled 'details' on the wire and the tab never appeared.
|
||||
|
||||
The failure is invisible: no error, no log line, and the panels still render -
|
||||
just in the wrong tab, underneath the Details cards.
|
||||
"""
|
||||
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def test_every_group_the_endpoints_plugin_declares_survives_resolution():
|
||||
declared = {
|
||||
str(panel.get('group') or '').strip().lower()
|
||||
for panel in EndpointsPlugin().get_asset_panels()
|
||||
}
|
||||
declared.discard('')
|
||||
unknown = declared - set(ASSET_PANEL_GROUPS)
|
||||
assert not unknown, (
|
||||
f'panels declare {sorted(unknown)}, which the server rewrites to '
|
||||
"'details' - the tab will not appear"
|
||||
)
|
||||
|
||||
|
||||
def test_serviceprocess_is_admitted():
|
||||
# Named explicitly: the generic test above would also pass if the
|
||||
# serviceprocess panels were deleted outright.
|
||||
assert 'serviceprocess' in ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
def test_reports_is_admitted():
|
||||
# Same reasoning as serviceprocess above. Operator Reports was moved out of
|
||||
# System Monitors into a tab of its own, which is the same three-copy
|
||||
# change that Phase 3 got wrong.
|
||||
assert 'reports' in ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
def test_details_remains_the_fallback():
|
||||
assert ASSET_PANEL_GROUPS[0] == 'details'
|
||||
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""The post-restore schema upgrade: who runs it, and what happens when it fails.
|
||||
|
||||
A restore loads the dump in the Flask-less backup sidecar and leaves a sentinel,
|
||||
because only the api container can run the migrations. These tests pin the two
|
||||
properties that failure taught us to care about: the upgrade must not depend on
|
||||
a browser staying open, and it must never run twice at once.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api import shopdbbackups
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backups_root(tmp_path, monkeypatch):
|
||||
"""Point the backup routes at a real, empty directory."""
|
||||
root = tmp_path / 'backups'
|
||||
root.mkdir()
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV, str(root))
|
||||
return root
|
||||
|
||||
|
||||
def _arm(root, setname='wj-2026-09-06T120000Z'):
|
||||
(root / shopdbbackups.UPGRADE_SENTINEL).write_text(setname, encoding='utf-8')
|
||||
return setname
|
||||
|
||||
|
||||
def test_only_one_claimant_wins_a_pending_upgrade(app, backups_root):
|
||||
"""Two workers racing the same sentinel must not both run Alembic."""
|
||||
setname = _arm(backups_root)
|
||||
with app.app_context():
|
||||
first = shopdbbackups._claim_upgrade()
|
||||
second = shopdbbackups._claim_upgrade()
|
||||
|
||||
assert first == setname
|
||||
assert second is None, 'a second worker claimed an upgrade already in flight'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists()
|
||||
assert (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
|
||||
|
||||
def test_claiming_nothing_is_not_an_error(app, backups_root):
|
||||
with app.app_context():
|
||||
assert shopdbbackups._claim_upgrade() is None
|
||||
|
||||
|
||||
def test_a_successful_finalize_clears_the_claim_and_says_done(app, backups_root,
|
||||
monkeypatch):
|
||||
setname = _arm(backups_root)
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', lambda: None)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is True
|
||||
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'done'
|
||||
assert status['setname'] == setname
|
||||
|
||||
|
||||
def test_a_failed_upgrade_parks_itself_instead_of_retrying_forever(app,
|
||||
backups_root,
|
||||
monkeypatch):
|
||||
"""A failing migration must not be re-armed automatically.
|
||||
|
||||
Putting the sentinel back would run the same broken chain on every poll for
|
||||
as long as the container lives.
|
||||
"""
|
||||
_arm(backups_root)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError('Can\'t locate revision identified by abc123')
|
||||
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', _boom)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is False
|
||||
|
||||
assert (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'a failed upgrade re-armed itself and will retry forever'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'failed'
|
||||
|
||||
|
||||
def test_the_endpoint_does_not_run_the_upgrade_itself(app, client, auth_headers,
|
||||
backups_root, monkeypatch):
|
||||
"""Alembic in a request can outrun the worker timeout and be killed mid-chain."""
|
||||
_arm(backups_root)
|
||||
ran = []
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade',
|
||||
lambda: ran.append(True))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert ran == [], 'the request ran the schema upgrade inline'
|
||||
assert response.get_json()['data']['queued'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'the endpoint consumed the sentinel the watcher needs to claim'
|
||||
|
||||
|
||||
def test_the_endpoint_re_arms_a_failed_upgrade(app, client, auth_headers,
|
||||
backups_root):
|
||||
"""Retrying a failed upgrade is what this endpoint is for now."""
|
||||
(backups_root / shopdbbackups.UPGRADE_FAILED).write_text('wj-set',
|
||||
encoding='utf-8')
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['retried'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).read_text() == 'wj-set'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
|
||||
|
||||
def test_nothing_pending_reports_nothing_pending(app, client, auth_headers,
|
||||
backups_root):
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['queued'] is False
|
||||
|
||||
|
||||
def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
|
||||
tmp_path, monkeypatch):
|
||||
"""No store behind the feature means say so, not 500 on a missing path."""
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV,
|
||||
str(tmp_path / 'nothing-here'))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPluginChainCompatibility:
|
||||
"""The restore gate has to see plugin chains, not just the core one.
|
||||
|
||||
The failure it exists for: roll code back to an older plugin revision and
|
||||
the CORE head can be identical, so a core-only gate passes a dump whose
|
||||
plugin tables the code has never heard of. It loads - destroying what was
|
||||
there - and only then dies in 'flask plugin upgrade-all'.
|
||||
"""
|
||||
|
||||
def _manifest(self, core_rev, plugins=None):
|
||||
manifest = {'datarev': core_rev, 'schemarev': {'core': [core_rev]}}
|
||||
if plugins is not None:
|
||||
manifest['dataplugins'] = plugins
|
||||
return manifest
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
assert known, 'expected this build to know its own core revisions'
|
||||
return sorted(known)[0]
|
||||
|
||||
def _real_plugin_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_plugin_revisions()
|
||||
assert known, 'expected this build to have plugin migration chains'
|
||||
name = sorted(known)[0]
|
||||
return name, sorted(known[name])[0]
|
||||
|
||||
def test_a_set_from_a_newer_plugin_chain_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, _ = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: 'thisrevisiondoesnotexist'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False, \
|
||||
'a dump carrying unknown plugin data was called restorable'
|
||||
|
||||
def test_a_set_whose_plugin_chains_are_known_is_allowed(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is True
|
||||
|
||||
def test_a_plugin_this_build_does_not_have_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
manifest = self._manifest(core, {'nosuchplugin': 'somerevision'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
def test_a_legacy_set_without_the_stamp_keeps_its_old_verdict(self, app):
|
||||
"""Sets taken before dataplugins existed must not become unrestorable."""
|
||||
core = self._real_core_rev(app)
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(self._manifest(core), {}) is True
|
||||
assert shopdbbackups._is_compatible(
|
||||
self._manifest(core, {}), {}) is True
|
||||
|
||||
def test_the_core_gate_still_refuses_an_unknown_core_revision(self, app):
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest('unknowncorerevision', {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
|
||||
class TestDatarevEvidence:
|
||||
"""An unreadable data revision must not be badged restorable."""
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
return sorted(known)[0]
|
||||
|
||||
def test_an_empty_datarev_is_no_evidence_not_a_legacy_set(self, app):
|
||||
"""backup.sh discards the mysql error, so datarev can be ''.
|
||||
|
||||
Falling back to the code-to-code head check there would badge the set
|
||||
compatible using the very comparison datarev was introduced to replace.
|
||||
"""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'datarev': '', 'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is False
|
||||
|
||||
def test_a_manifest_with_no_datarev_key_still_falls_back(self, app):
|
||||
"""A genuine legacy set predates the field entirely and keeps working."""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is True
|
||||
@@ -94,7 +94,23 @@ def documentation_files():
|
||||
|
||||
def test_there_are_docs_to_check():
|
||||
"""A path change that silently matched nothing would make this suite pass
|
||||
while checking absolutely nothing."""
|
||||
while checking absolutely nothing.
|
||||
|
||||
FLAG FOR DOC AUTHORS: if this fails with a SMALL number (3, not 0), you have
|
||||
almost certainly just committed a docs/*.md file. Don't lower the threshold
|
||||
to make it pass - that is the exact failure this assertion exists to catch,
|
||||
and it would silently disable the whole scrub suite.
|
||||
|
||||
docs/*.md prose is wiki-published and MUST stay untracked. The gates decide
|
||||
'is this a published checkout?' by the ABSENCE of docs/*.md, so one tracked
|
||||
prose file makes CI look internal while carrying no prose, and takes down
|
||||
backend AND gates together. docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md
|
||||
did exactly this and held the branch red for eight runs; both were untracked
|
||||
(kept on disk) and .gitignore now ignores docs/*.md wholesale.
|
||||
|
||||
Authoring a doc is still fine - write it in docs/, it just stays local and
|
||||
goes to the wiki. docs/notes/ and docs/openapi.json are unaffected.
|
||||
"""
|
||||
assert len(documentation_files()) > 20
|
||||
|
||||
|
||||
|
||||
@@ -121,7 +121,14 @@ def test_a_stated_plugin_count_matches_the_tree():
|
||||
|
||||
def test_the_map_itself_is_current():
|
||||
"""The pointer target has to be right, or every document pointing at it is
|
||||
wrong at one remove."""
|
||||
wrong at one remove.
|
||||
|
||||
FLAG FOR DOC AUTHORS: if this fails saying PROJECT-MAP.md is missing, the
|
||||
likely cause is not a missing map - it is that you committed a docs/*.md
|
||||
file, which stopped this module from skipping as a published checkout. The
|
||||
map is generated and correctly untracked. See the note in
|
||||
tests/test_docs_publishable.py::test_there_are_docs_to_check.
|
||||
"""
|
||||
mapfile = DOCS / 'PROJECT-MAP.md'
|
||||
assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py'
|
||||
text = mapfile.read_text()
|
||||
|
||||
@@ -101,7 +101,12 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
# 0010 renames the shipped business policy off the originating site's
|
||||
# designator on installs that already stamped 0007 (ADR-015).
|
||||
# 0011 adds the trayversion cache column to endpointstates.
|
||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0011trayversion'
|
||||
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
|
||||
# replayed Windows event record.
|
||||
# 0013 adds the inventory columns.
|
||||
# 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry
|
||||
# columns (boot info, reboot reasons, Defender health, NIC link state).
|
||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0016dellupdates'
|
||||
|
||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||
# core chain never owned.
|
||||
|
||||
90
tests/test_plugins/test_endpoints_agentupload.py
Normal file
90
tests/test_plugins/test_endpoints_agentupload.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Agent binary upload version gating: PE-fallback and precedence.
|
||||
|
||||
The upload route no longer rejects the version at raw input. A typed value is
|
||||
only a hint; the dotted-numeric FILE version in the binary's PE resource is the
|
||||
authority. The route falls back to it when the typed value is missing or not
|
||||
dotted-numeric, and only refuses when neither yields a usable version.
|
||||
|
||||
Building a real PE with a RT_VERSION resource inline is impractical (see the
|
||||
agentstore unit tests), so these API tests monkeypatch read_pe_file_version to
|
||||
stand in for the parser. PE-magic bytes keep looks_like_pe happy so the request
|
||||
reaches the version gate.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from plugins.endpoints.api import agentstore
|
||||
from plugins.endpoints.api import agentupload
|
||||
from shopdb.config import Config
|
||||
|
||||
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 128
|
||||
|
||||
|
||||
def _upload(client, headers, monkeypatch, detected, typed=None):
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version',
|
||||
lambda data: detected)
|
||||
form = {'file': (io.BytesIO(_PE_BYTES), 'shopdbagent.exe')}
|
||||
if typed is not None:
|
||||
form['version'] = typed
|
||||
return client.post(
|
||||
UPLOAD_URL.format('agent'),
|
||||
data=form,
|
||||
content_type='multipart/form-data',
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_upload_falls_back_to_pe_version_when_typed_is_missing(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
payload = response.get_json()['data']
|
||||
assert payload['current'] == '1.5.0.0'
|
||||
assert any(v['version'] == '1.5.0.0' for v in payload['versions'])
|
||||
|
||||
|
||||
def test_upload_falls_back_when_typed_is_not_dotted_numeric(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0',
|
||||
typed='1.5.0+1a1bf0640f956634dada1b34840')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['current'] == '1.5.0.0'
|
||||
|
||||
|
||||
def test_upload_prefers_valid_typed_version_over_pe(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, '9.9.9.9',
|
||||
typed='1.6.3')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['current'] == '1.6.3'
|
||||
|
||||
|
||||
def test_upload_rejects_when_neither_typed_nor_pe_is_usable(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, None)
|
||||
assert response.status_code == 400
|
||||
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_upload_cap_admits_a_self_contained_single_file_publish():
|
||||
"""The tray publishes ~69 MB; the cap must clear a real binary.
|
||||
|
||||
This is the bug that presented as "upload does not detect version": the
|
||||
size check returns before the PE is parsed, so an oversized file looks
|
||||
like a parser failure to the operator. Pinned at 100 MB rather than the
|
||||
observed 69 MB so runtime growth does not silently re-break it.
|
||||
"""
|
||||
assert agentupload._MAX_UPLOAD_BYTES >= 100 * 1024 * 1024
|
||||
|
||||
|
||||
def test_request_cap_stays_above_the_agent_upload_cap():
|
||||
"""MAX_CONTENT_LENGTH bounds the whole multipart envelope, not the file.
|
||||
|
||||
Setting the two equal rejects a file sitting exactly at the agent cap,
|
||||
because the boundaries and form fields push the request over. They must
|
||||
move together, with real headroom between them.
|
||||
"""
|
||||
envelope = Config.MAX_CONTENT_LENGTH
|
||||
assert envelope > agentupload._MAX_UPLOAD_BYTES
|
||||
assert envelope - agentupload._MAX_UPLOAD_BYTES >= 1024 * 1024
|
||||
168
tests/test_plugins/test_endpoints_dellupdates.py
Normal file
168
tests/test_plugins/test_endpoints_dellupdates.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Pending Dell Command | Update reporting: ingest and status-card exposure.
|
||||
|
||||
The agent's shopdb-dellupdates utility reads the log the \\ShopDB-DellCommandScan
|
||||
scheduled task writes and reports a short summary on the normal telemetry
|
||||
check-in. These tests cover the ingest write path and the three-state rendering
|
||||
on the status panel, which is the part most likely to be broken by a well-meaning
|
||||
simplification: "never scanned" must NOT render as "No".
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
|
||||
STATUS_URL = '/api/endpoints/asset/{}/status'
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
"""Create a computer Asset + Computer row; return the asset."""
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
|
||||
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
|
||||
if not ctype:
|
||||
ctype = ComputerType(computertype='CMM PC')
|
||||
db.session.add(ctype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname,
|
||||
computertypeid=ctype.computertypeid))
|
||||
db.session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _checkin(db, hostname, payload):
|
||||
"""Run a check-in for hostname with the extra payload keys merged in."""
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
body = {'hostname': hostname}
|
||||
body.update(payload)
|
||||
EndpointsPlugin().apply_collector_payload(body)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _status_fields(client, asset, auth_headers):
|
||||
response = client.get(STATUS_URL.format(asset.assetid), headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
return {f['label']: f['value']
|
||||
for f in response.get_json()['data']['fields']}
|
||||
|
||||
|
||||
def test_checkin_persists_the_dell_summary(client, db, app, auth_headers):
|
||||
"""A reported dellupdates payload is stored on the endpoint state."""
|
||||
from plugins.endpoints.models.endpoint import EndpointState
|
||||
|
||||
_computer(db, 'DELL01')
|
||||
_checkin(db, 'DELL01', {'dellupdates': {
|
||||
'status': 'ok', 'count': 1, 'truncated': False,
|
||||
'updates': [{'name': 'Dell BIOS', 'severity': 'Urgent',
|
||||
'version': '1.2.3'}],
|
||||
}})
|
||||
|
||||
state = EndpointState.query.filter_by(
|
||||
assetid=Asset.query.filter_by(assetnumber='DELL01').first().assetid
|
||||
).first()
|
||||
stored = json.loads(state.dellupdatesjson)
|
||||
assert stored['count'] == 1
|
||||
assert stored['updates'][0]['name'] == 'Dell BIOS'
|
||||
|
||||
|
||||
def test_pending_updates_show_yes_with_a_count(client, db, app, auth_headers):
|
||||
"""A scan with pending items renders Yes plus how many."""
|
||||
asset = _computer(db, 'DELL02')
|
||||
_checkin(db, 'DELL02', {'dellupdates': {
|
||||
'status': 'ok', 'count': 3, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Yes (3)'
|
||||
|
||||
|
||||
def test_a_clean_scan_shows_no(client, db, app, auth_headers):
|
||||
"""A scan that found nothing renders No, not a hidden row."""
|
||||
asset = _computer(db, 'DELL03')
|
||||
_checkin(db, 'DELL03', {'dellupdates': {
|
||||
'status': 'ok', 'count': 0, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'No'
|
||||
|
||||
|
||||
def test_a_never_scanned_endpoint_says_so(client, db, app, auth_headers):
|
||||
"""Never scanned renders as its own visible state, not as No and not hidden.
|
||||
|
||||
Two failure modes are being guarded at once: showing 'No' would claim the
|
||||
machine is current when nobody ever checked, and hiding the row entirely
|
||||
reads to an operator as a broken panel.
|
||||
"""
|
||||
asset = _computer(db, 'DELL04')
|
||||
_checkin(db, 'DELL04', {})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Never scanned'
|
||||
|
||||
|
||||
def test_an_unknown_status_says_never_scanned(client, db, app, auth_headers):
|
||||
"""The utility's own 'never scanned' answer renders as never scanned."""
|
||||
asset = _computer(db, 'DELL05')
|
||||
_checkin(db, 'DELL05', {'dellupdates': {
|
||||
'status': 'unknown', 'count': 0, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Never scanned'
|
||||
|
||||
|
||||
def test_a_failed_scan_read_is_distinct_from_never_scanned(client, db, app,
|
||||
auth_headers):
|
||||
"""An unreadable scan log is its own state, not silence and not clean."""
|
||||
asset = _computer(db, 'DELL06')
|
||||
_checkin(db, 'DELL06', {'dellupdates': {
|
||||
'status': 'error', 'count': 0, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Unknown (scan unreadable)'
|
||||
|
||||
|
||||
def test_a_malformed_blob_does_not_break_the_panel(client, db, app, auth_headers):
|
||||
"""A corrupt cached payload degrades to the unknown state, not a 500."""
|
||||
from plugins.endpoints.models.endpoint import EndpointState
|
||||
|
||||
asset = _computer(db, 'DELL07')
|
||||
_checkin(db, 'DELL07', {})
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
|
||||
state.dellupdatesjson = 'not json at all'
|
||||
db.session.commit()
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Never scanned'
|
||||
|
||||
|
||||
def test_an_omitted_key_preserves_the_last_result(client, db, app, auth_headers):
|
||||
"""A check-in without the key does not erase a previously reported scan.
|
||||
|
||||
The agent omits dellupdates entirely when the collector utility is absent,
|
||||
so overwriting to null would discard a still-valid result.
|
||||
"""
|
||||
asset = _computer(db, 'DELL08')
|
||||
_checkin(db, 'DELL08', {'dellupdates': {
|
||||
'status': 'ok', 'count': 2, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
_checkin(db, 'DELL08', {})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Yes (2)'
|
||||
108
tests/test_plugins/test_endpoints_disk_health.py
Normal file
108
tests/test_plugins/test_endpoints_disk_health.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Disk health rows: a dying drive must never render as a healthy one.
|
||||
|
||||
The agent reports drive health from two sources because neither covers the
|
||||
fleet. MSFT_PhysicalDisk is the only one that reports NVMe - verified on a
|
||||
two-NVMe box where the classic ATA SMART class returned zero instances while
|
||||
both drives were present and healthy. Win32_DiskDrive covers ATA/SATA and hosts
|
||||
where the Storage namespace is missing.
|
||||
|
||||
That means the panel can receive the same physical drive twice, from sources
|
||||
that may disagree, plus rows where health could not be read at all. Every test
|
||||
here pins a way this card could start showing a failing or unreadable drive as
|
||||
fine, which is the only failure mode that matters: nobody investigates a disk
|
||||
that looks healthy.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.api.routes import _diskhealth_rows
|
||||
|
||||
|
||||
def _drive(deviceid, predictfailure, healthstatus='Healthy',
|
||||
source='physicaldisk', model='Samsung SSD'):
|
||||
return {
|
||||
'deviceid': deviceid,
|
||||
'model': model,
|
||||
'mediatype': 'SSD',
|
||||
'bustype': 'NVMe',
|
||||
'healthstatus': healthstatus,
|
||||
'predictfailure': predictfailure,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def test_a_failing_drive_sorts_above_healthy_ones():
|
||||
"""On a multi-disk box the bad drive must not be below the fold."""
|
||||
rows = _diskhealth_rows([
|
||||
_drive('0', False),
|
||||
_drive('1', True, healthstatus='Unhealthy'),
|
||||
_drive('2', False),
|
||||
])
|
||||
|
||||
assert [row['deviceid'] for row in rows] == ['1', '0', '2']
|
||||
assert rows[0]['verdict'] == 'Failure predicted'
|
||||
|
||||
|
||||
def test_a_drive_that_did_not_answer_reads_as_unknown_not_ok():
|
||||
"""Null predictfailure is an unread drive, not a healthy one."""
|
||||
rows = _diskhealth_rows([_drive('0', None, healthstatus='')])
|
||||
|
||||
assert rows[0]['verdict'] == 'Unknown'
|
||||
assert rows[0]['healthstatus'] == 'Unknown'
|
||||
|
||||
|
||||
def test_unknown_outranks_ok_so_silence_is_not_buried_under_health():
|
||||
"""A drive that stopped answering is a weaker claim than one saying OK."""
|
||||
rows = _diskhealth_rows([
|
||||
_drive('0', False),
|
||||
_drive('1', None),
|
||||
])
|
||||
|
||||
assert [row['verdict'] for row in rows] == ['Unknown', 'OK']
|
||||
|
||||
|
||||
def test_both_sources_survive_for_the_same_hardware():
|
||||
"""Two disagreeing views of one drive is the signal, not noise.
|
||||
|
||||
Collapsing them would hide the case worth looking at: one source calling a
|
||||
drive healthy while the other predicts failure.
|
||||
"""
|
||||
rows = _diskhealth_rows([
|
||||
_drive('0', False, source='physicaldisk'),
|
||||
_drive('0', True, healthstatus='Pred Fail', source='diskdrive'),
|
||||
])
|
||||
|
||||
assert len(rows) == 2
|
||||
assert {row['source'] for row in rows} == {'physicaldisk', 'diskdrive'}
|
||||
assert rows[0]['verdict'] == 'Failure predicted'
|
||||
|
||||
|
||||
def test_input_order_does_not_change_which_drive_is_shown_first():
|
||||
"""The verdict ranking must decide the order, not arrival order."""
|
||||
healthy = _drive('0', False)
|
||||
failing = _drive('1', True)
|
||||
|
||||
forward = _diskhealth_rows([healthy, failing])
|
||||
backward = _diskhealth_rows([failing, healthy])
|
||||
|
||||
assert forward == backward
|
||||
assert forward[0]['deviceid'] == '1'
|
||||
|
||||
|
||||
def test_an_nvme_drive_is_reported_rather_than_dropped():
|
||||
"""The whole reason for the second source: ATA SMART returns nothing here."""
|
||||
rows = _diskhealth_rows([_drive('0', False, source='physicaldisk')])
|
||||
|
||||
assert rows[0]['bustype'] == 'NVMe'
|
||||
assert rows[0]['verdict'] == 'OK'
|
||||
|
||||
|
||||
def test_a_malformed_payload_does_not_take_the_panel_down():
|
||||
rows = _diskhealth_rows([_drive('0', False), 'not a dict', None])
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['deviceid'] == '0'
|
||||
|
||||
|
||||
def test_a_missing_or_wrong_shaped_payload_is_an_empty_panel():
|
||||
assert _diskhealth_rows(None) == []
|
||||
assert _diskhealth_rows({}) == []
|
||||
assert _diskhealth_rows([]) == []
|
||||
138
tests/test_plugins/test_endpoints_ip_history.py
Normal file
138
tests/test_plugins/test_endpoints_ip_history.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""IP History and IP Configuration, carved out of the old Details Network card.
|
||||
|
||||
These read core Communication rows, which accumulate as an ACCIDENTAL history:
|
||||
_record_interfaces matches an incoming NIC on MAC then on IP and inserts when
|
||||
neither matches, and never deletes. So an address the machine has not held for
|
||||
a year sits next to the current one. These tests pin the reading of that, not
|
||||
the collecting.
|
||||
|
||||
The date columns are the delicate part. modifieddate only moves when a field on
|
||||
the row actually changes, so an address still held and reported unchanged keeps
|
||||
an old stamp. It is labelled 'Last changed' for that reason and must not drift
|
||||
back to 'Last seen'.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.endpoints.api.routes import (
|
||||
_IPHISTORY_COLUMNS,
|
||||
_iphistory_status,
|
||||
_isostamp,
|
||||
)
|
||||
|
||||
|
||||
class FakeComm:
|
||||
"""Only the columns these two routes read."""
|
||||
|
||||
def __init__(self, ipaddress='10.0.0.1', macaddress='AA:BB',
|
||||
isprimary=False, ismachinenetwork=False, isdhcp=False,
|
||||
subnetmask='', gateway='', dns1='', dns2='',
|
||||
createddate=None, modifieddate=None):
|
||||
self.ipaddress = ipaddress
|
||||
self.macaddress = macaddress
|
||||
self.isprimary = isprimary
|
||||
self.ismachinenetwork = ismachinenetwork
|
||||
self.isdhcp = isdhcp
|
||||
self.subnetmask = subnetmask
|
||||
self.gateway = gateway
|
||||
self.dns1 = dns1
|
||||
self.dns2 = dns2
|
||||
self.createddate = createddate
|
||||
self.modifieddate = modifieddate
|
||||
|
||||
|
||||
def test_an_apipa_address_is_named_as_a_dhcp_failure():
|
||||
# 169.254.x.x is not an address the machine was given - it is what Windows
|
||||
# assigns when DHCP failed. Calling it 'Previous' would file a past network
|
||||
# fault as a past subnet.
|
||||
assert _iphistory_status(FakeComm(ipaddress='169.254.122.88')) == 'APIPA (no DHCP)'
|
||||
|
||||
|
||||
def test_apipa_wins_over_primary():
|
||||
# A box currently failing DHCP can have its APIPA address flagged primary
|
||||
# by the collector. The fault is the more useful thing to say.
|
||||
comm = FakeComm(ipaddress='169.254.1.1', isprimary=True)
|
||||
assert _iphistory_status(comm) == 'APIPA (no DHCP)'
|
||||
|
||||
|
||||
def test_a_normal_address_is_not_mistaken_for_apipa():
|
||||
# Guards a substring match: 169.254 must be a prefix, not found anywhere.
|
||||
assert _iphistory_status(FakeComm(ipaddress='10.169.254.5')) == 'Previous'
|
||||
|
||||
|
||||
def test_the_primary_address_is_marked():
|
||||
assert _iphistory_status(FakeComm(isprimary=True)) == 'Primary'
|
||||
|
||||
|
||||
def test_a_machine_network_address_is_distinguished_from_a_stale_one():
|
||||
# ismachinenetwork means it talks to the machine, not that it is old.
|
||||
assert _iphistory_status(FakeComm(ismachinenetwork=True)) == 'Machine network'
|
||||
|
||||
|
||||
def test_everything_else_reads_as_previous():
|
||||
assert _iphistory_status(FakeComm()) == 'Previous'
|
||||
|
||||
|
||||
def test_a_missing_timestamp_yields_null_not_a_crash():
|
||||
# createddate is nullable on legacy rows imported before BaseModel stamps.
|
||||
assert _isostamp(None) is None
|
||||
|
||||
|
||||
def test_a_timestamp_is_emitted_as_utc_iso():
|
||||
# Matches every other timestamp this file emits; the client formats it to
|
||||
# the viewer's locale rather than being pinned to the server's.
|
||||
stamp = _isostamp(datetime(2026, 9, 5, 11, 30, 0))
|
||||
assert stamp == '2026-09-05T11:30:00Z'
|
||||
|
||||
|
||||
def test_the_second_date_is_not_labelled_last_seen():
|
||||
# The whole reason for the label. modifieddate cannot support 'last seen':
|
||||
# an unchanged address reported daily keeps a months-old stamp.
|
||||
labels = {col['key']: col['label'] for col in _IPHISTORY_COLUMNS}
|
||||
assert labels['lastchanged'] == 'Last changed'
|
||||
assert 'seen' not in labels['lastchanged'].lower()
|
||||
assert labels['firstseen'] == 'First seen'
|
||||
|
||||
|
||||
def test_the_history_ordering_compiles_on_mysql():
|
||||
# Regression: the first cut ordered with .nullslast(), which is valid
|
||||
# ANSI/Postgres but blows up on MySQL with a 1064 syntax error. The test
|
||||
# suite runs on SQLite, so it passed and the page still 500'd in the
|
||||
# browser. Compile the real ordering against the MySQL dialect so the
|
||||
# dialect gap is caught here instead of in a container log.
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from shopdb.api import Communication
|
||||
|
||||
ordering = (select(Communication.ipaddress)
|
||||
.where(Communication.assetid == 1)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc()))
|
||||
|
||||
compiled = str(ordering.compile(dialect=mysql.dialect()))
|
||||
assert 'NULLS LAST' not in compiled.upper()
|
||||
|
||||
|
||||
def test_every_panel_handler_still_has_a_route():
|
||||
# Regression: inserting the ipconfig route directly above panel_applications
|
||||
# ate that function's @endpoints_bp.route line, leaving a handler decorated
|
||||
# with require_permission only. It imported fine and every test passed; the
|
||||
# endpoint was simply gone. Assert each panel_* handler is actually mapped.
|
||||
import re
|
||||
|
||||
from plugins.endpoints.api import routes as routes_module
|
||||
|
||||
source = Path(routes_module.__file__).read_text(encoding='utf-8')
|
||||
handlers = re.findall(r'^def (panel_\w+)', source, re.MULTILINE)
|
||||
assert handlers, 'expected panel handlers in routes.py'
|
||||
|
||||
routed = set()
|
||||
for match in re.finditer(r'^@endpoints_bp\.route\(', source, re.MULTILINE):
|
||||
tail = source[match.start():]
|
||||
found = re.search(r'^def (\w+)', tail, re.MULTILINE)
|
||||
if found:
|
||||
routed.add(found.group(1))
|
||||
|
||||
assert set(handlers) - routed == set()
|
||||
49
tests/test_plugins/test_endpoints_link_label.py
Normal file
49
tests/test_plugins/test_endpoints_link_label.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""NIC link label: an unknown link must never read as a working one.
|
||||
|
||||
Link speed and duplex are paired onto the interface rows from a second WMI
|
||||
class, so three states are possible: read successfully, unreadable, and
|
||||
disconnected. Verified on a live box that a DISCONNECTED adapter returns empty
|
||||
Speed and FullDuplex - two of four NICs on the test machine were in that state.
|
||||
|
||||
That is the trap these tests pin. Defaulting an absent speed to 0 or an absent
|
||||
duplex to False would report every unplugged NIC as half duplex, which is the
|
||||
exact signature of the cable/port fault this field exists to catch. A column
|
||||
that cries fault on every disconnected NIC is a column nobody reads.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.api.routes import _link_label
|
||||
|
||||
|
||||
def test_a_gigabit_full_duplex_link_is_not_annotated():
|
||||
"""The normal case should be quiet so the abnormal case stands out."""
|
||||
assert _link_label(1000000000, True) == '1 Gb'
|
||||
|
||||
|
||||
def test_half_duplex_is_called_out_because_it_is_the_actionable_case():
|
||||
assert _link_label(100000000, False) == '100 Mb half duplex'
|
||||
|
||||
|
||||
def test_a_disconnected_adapter_reads_as_unknown_not_half_duplex():
|
||||
"""The live-box case: disconnected NICs return empty speed and duplex.
|
||||
|
||||
Reading this as half duplex would flag every unplugged NIC as a fault.
|
||||
"""
|
||||
assert _link_label(None, None) == '-'
|
||||
|
||||
|
||||
def test_an_unreadable_duplex_on_a_live_link_is_not_assumed_half():
|
||||
"""Speed known, duplex not. Unknown duplex must not render as a fault."""
|
||||
assert _link_label(10000000000, None) == '10 Gb'
|
||||
|
||||
|
||||
def test_zero_speed_is_unknown_rather_than_a_zero_speed_link():
|
||||
assert _link_label(0, True) == '-'
|
||||
|
||||
|
||||
def test_sub_gigabit_speeds_stay_in_megabits():
|
||||
assert _link_label(10000000, True) == '10 Mb'
|
||||
|
||||
|
||||
def test_speeds_do_not_render_trailing_zeros():
|
||||
"""2.5Gb NICs are common now; the label should not read '2.5000 Gb'."""
|
||||
assert _link_label(2500000000, True) == '2.5 Gb'
|
||||
186
tests/test_plugins/test_endpoints_mapped_drives.py
Normal file
186
tests/test_plugins/test_endpoints_mapped_drives.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Mapped-drive merge: two disagreeing Windows sources into one honest row.
|
||||
|
||||
Windows reports mapped drives twice - Win32_NetworkConnection (the 'net use'
|
||||
table) and Get-SmbMapping - and the two do not agree. Captured on one machine at
|
||||
one moment, same two mappings, Get-SmbMapping said OK for both while net use
|
||||
said Disconnected and Reconnecting. Trusting the structured source alone would
|
||||
have rendered a healthy Q: on a box whose Q: was down.
|
||||
|
||||
So the probe collects both without reconciling them and _mapped_drives() decides
|
||||
what an operator is shown. Every test here pins a way that decision could
|
||||
silently start rendering a broken drive as a working one, which is the only
|
||||
failure mode of this card that matters: nobody reports a drive that looks fine.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.api.routes import _mapped_drives
|
||||
|
||||
|
||||
def _netuse(localname, remotename, status):
|
||||
return {'localname': localname, 'remotename': remotename, 'status': status}
|
||||
|
||||
|
||||
def _smb(localpath, remotepath, status):
|
||||
return {'localpath': localpath, 'remotepath': remotepath, 'status': status}
|
||||
|
||||
|
||||
def test_the_worse_of_two_disagreeing_sources_is_what_gets_shown():
|
||||
"""The real capture: Get-SmbMapping says OK, net use says Disconnected.
|
||||
|
||||
The drive was down. Showing OK because one source said so is the exact bug
|
||||
this merge exists to prevent.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('Q:', r'\\server\specdata', 'Disconnected')],
|
||||
'smbmapping': [_smb('Q:', r'\\server\specdata', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'Q: \\server\specdata (Disconnected)']
|
||||
|
||||
|
||||
def test_reconnecting_is_worse_than_ok_and_survives_the_merge():
|
||||
"""Reconnecting is a real status from the capture, not a synonym for OK."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('S:', r'\\server\shared', 'Reconnecting')],
|
||||
'smbmapping': [_smb('S:', r'\\server\shared', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'S: \\server\shared (Reconnecting)']
|
||||
|
||||
|
||||
def test_a_drive_both_sources_call_healthy_is_not_annotated():
|
||||
"""Only problems are labelled; tagging every drive '(OK)' buries the bad one."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('H:', r'\\server\home', 'OK')],
|
||||
'smbmapping': [_smb('H:', r'\\server\home', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'H: \\server\home']
|
||||
|
||||
|
||||
def test_a_drive_only_one_source_knows_about_is_still_reported():
|
||||
"""Get-SmbMapping is absent on some builds and session types.
|
||||
|
||||
Requiring agreement between the sources would hide every mapped drive on
|
||||
those machines rather than showing what is known about them.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('P:', r'\\server\programs', 'Disconnected')],
|
||||
'smbmapping': [],
|
||||
})
|
||||
|
||||
assert rows == [r'P: \\server\programs (Disconnected)']
|
||||
|
||||
|
||||
def test_the_smbmapping_key_missing_entirely_degrades_to_netuse():
|
||||
"""Old agents predate the second source; the panel must still work.
|
||||
|
||||
The server ships ahead of the agent republish, so this is the live shape
|
||||
until every endpoint has the new probe.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('Q:', r'\\server\specdata', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'Q: \\server\specdata']
|
||||
|
||||
|
||||
def test_a_drive_with_no_status_from_either_source_reads_as_unknown():
|
||||
"""A blank annotation reads as healthy, so a failed check must not be blank."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('Z:', r'\\server\scratch', None)],
|
||||
'smbmapping': [],
|
||||
})
|
||||
|
||||
assert rows == [r'Z: \\server\scratch (Unknown)']
|
||||
|
||||
|
||||
def test_an_unrecognised_status_is_not_assumed_healthy():
|
||||
"""Unknown vocabulary ranks below OK deliberately.
|
||||
|
||||
Paired against OK specifically: a status the server does not recognise must
|
||||
LOSE to a healthy one, or an unseen fault renders green. Pairing it against
|
||||
a known-bad status would prove nothing, since the known-bad value would win
|
||||
however unknowns are ranked.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('X:', r'\\server\thing', 'SomeNewStatus')],
|
||||
'smbmapping': [_smb('X:', r'\\server\thing', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'X: \\server\thing (SomeNewStatus)']
|
||||
|
||||
|
||||
def test_a_known_bad_status_beats_an_unrecognised_one():
|
||||
"""Unavailable outranks an unknown string; the worst named state wins."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('X:', r'\\server\thing', 'Unavailable')],
|
||||
'smbmapping': [_smb('X:', r'\\server\thing', 'SomeNewStatus')],
|
||||
})
|
||||
|
||||
assert rows == [r'X: \\server\thing (Unavailable)']
|
||||
|
||||
|
||||
def test_which_source_reported_a_status_does_not_change_the_verdict():
|
||||
"""The same pair of statuses must resolve identically whichever side sent it.
|
||||
|
||||
_worse_drive_status ranks by severity and breaks ties by argument order, so
|
||||
if two statuses ever rank EQUAL the answer silently depends on which source
|
||||
happened to report which - and a drive would read healthy or broken based on
|
||||
enumeration order. Asserting both directions is what makes an accidental tie
|
||||
visible; a single-direction test passes on luck.
|
||||
"""
|
||||
remote = r'\\server\thing'
|
||||
pairs = [
|
||||
('OK', 'Disconnected'),
|
||||
('OK', 'SomeNewStatus'),
|
||||
('Reconnecting', 'Unavailable'),
|
||||
('OK', 'Reconnecting'),
|
||||
]
|
||||
|
||||
for first, second in pairs:
|
||||
forward = _mapped_drives({
|
||||
'netuse': [_netuse('X:', remote, first)],
|
||||
'smbmapping': [_smb('X:', remote, second)],
|
||||
})
|
||||
backward = _mapped_drives({
|
||||
'netuse': [_netuse('X:', remote, second)],
|
||||
'smbmapping': [_smb('X:', remote, first)],
|
||||
})
|
||||
assert forward == backward, (
|
||||
'statuses {!r}/{!r} resolve differently depending on which source '
|
||||
'reported them'.format(first, second))
|
||||
|
||||
|
||||
def test_the_two_sources_are_paired_by_drive_letter_not_by_order():
|
||||
"""The sources enumerate independently and need not agree on ordering."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [
|
||||
_netuse('Q:', r'\\server\specdata', 'OK'),
|
||||
_netuse('S:', r'\\server\shared', 'OK'),
|
||||
],
|
||||
'smbmapping': [
|
||||
_smb('S:', r'\\server\shared', 'Disconnected'),
|
||||
_smb('Q:', r'\\server\specdata', 'OK'),
|
||||
],
|
||||
})
|
||||
|
||||
assert rows == [
|
||||
r'Q: \\server\specdata',
|
||||
r'S: \\server\shared (Disconnected)',
|
||||
]
|
||||
|
||||
|
||||
def test_no_user_context_at_all_yields_no_rows():
|
||||
"""Nobody signed in is a valid state, not a fault."""
|
||||
assert _mapped_drives({}) == []
|
||||
|
||||
|
||||
def test_malformed_entries_are_skipped_rather_than_raising():
|
||||
"""The payload is agent-supplied; a bad row must not blank the whole card."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': ['not-a-dict', None, {'status': 'OK'},
|
||||
_netuse('Q:', r'\\server\specdata', 'Disconnected')],
|
||||
'smbmapping': 'not-a-list',
|
||||
})
|
||||
|
||||
assert rows == [r'Q: \\server\specdata (Disconnected)']
|
||||
118
tests/test_plugins/test_endpoints_mapped_drives_card.py
Normal file
118
tests/test_plugins/test_endpoints_mapped_drives_card.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""The mapped drives card: structured rows, badges, and row order.
|
||||
|
||||
The merge logic itself is pinned in test_endpoints_mapped_drives.py. These
|
||||
tests cover what the CARD adds on top: a per-drive badge colour and the row
|
||||
order that decides what an operator sees first.
|
||||
|
||||
The failure mode this guards is silent: a broken drive rendered green, or
|
||||
sorted below the fold, leaves the card looking healthy and tells nobody.
|
||||
"""
|
||||
from plugins.endpoints.api.routes import (
|
||||
_mapped_drive_rows, _mapped_drives, _mapped_drives_unhealthy_first,
|
||||
)
|
||||
|
||||
|
||||
def _context(netuse=None, smbmapping=None):
|
||||
context = {}
|
||||
if netuse is not None:
|
||||
context['netuse'] = netuse
|
||||
if smbmapping is not None:
|
||||
context['smbmapping'] = smbmapping
|
||||
return context
|
||||
|
||||
|
||||
def _netuse(local, remote, status):
|
||||
return {'localname': local, 'remotename': remote, 'status': status}
|
||||
|
||||
|
||||
def test_a_healthy_drive_is_green():
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
|
||||
assert rows[0]['statecolor'] == 'green'
|
||||
assert rows[0]['statuslabel'] == 'OK'
|
||||
|
||||
|
||||
def test_a_disconnected_drive_is_red():
|
||||
rows = _mapped_drive_rows(
|
||||
_context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')]))
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_connected_counts_as_healthy_alongside_ok():
|
||||
"""net use and Get-SmbMapping use different words for the same state."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Connected')]))
|
||||
assert rows[0]['statecolor'] == 'green'
|
||||
|
||||
|
||||
def test_a_drive_with_no_status_is_red_not_green():
|
||||
"""An uncoloured row reads as working. Unknown health is not health."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', None)]))
|
||||
assert rows[0]['statuslabel'] == 'Unknown'
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_an_unrecognised_status_is_not_coloured_green():
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Weird')]))
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_the_worse_of_two_sources_drives_the_badge_colour():
|
||||
"""The merge already picks the worse status; the badge must follow it."""
|
||||
rows = _mapped_drive_rows(_context(
|
||||
netuse=[_netuse('Z:', r'\\srv\share', 'OK')],
|
||||
smbmapping=[{'localpath': 'Z:', 'remotepath': r'\\srv\share',
|
||||
'status': 'Disconnected'}],
|
||||
))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_the_remote_path_is_carried_as_its_own_field():
|
||||
"""The card shows the path separately rather than glued into the title."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
|
||||
assert rows[0]['drive'] == 'Z:'
|
||||
assert rows[0]['remote'] == r'\\srv\share'
|
||||
|
||||
|
||||
def test_the_summary_line_and_the_card_agree_about_a_broken_drive():
|
||||
"""Both are built from the same rows, so they cannot drift apart."""
|
||||
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')])
|
||||
assert _mapped_drive_rows(context)[0]['statecolor'] == 'red'
|
||||
assert '(Disconnected)' in _mapped_drives(context)[0]
|
||||
|
||||
|
||||
def test_a_healthy_drive_is_not_annotated_in_the_summary_line():
|
||||
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')])
|
||||
assert _mapped_drives(context) == [r'Z: \\srv\share']
|
||||
|
||||
|
||||
def test_no_user_context_yields_no_rows():
|
||||
assert _mapped_drive_rows({}) == []
|
||||
|
||||
|
||||
def test_broken_drives_sort_above_healthy_ones():
|
||||
"""The broken one must be on screen without scrolling past nine good ones."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[
|
||||
_netuse('H:', r'\\srv\home', 'OK'),
|
||||
_netuse('S:', r'\\srv\shared', 'OK'),
|
||||
_netuse('Z:', r'\\srv\dead', 'Disconnected'),
|
||||
]))
|
||||
ordered = _mapped_drives_unhealthy_first(rows)
|
||||
assert [row['drive'] for row in ordered] == ['Z:', 'H:', 'S:']
|
||||
|
||||
|
||||
def test_drive_letter_order_survives_within_each_health_group():
|
||||
"""Sorting only on health keeps the list from reshuffling on refresh.
|
||||
|
||||
The unhealthy drives here carry DIFFERENT status words, chosen so that
|
||||
sorting on the status text would reorder them (Disconnected before
|
||||
Unavailable) against drive-letter order. A secondary sort key would pass
|
||||
a test where every broken drive said the same thing.
|
||||
"""
|
||||
rows = _mapped_drive_rows(_context(netuse=[
|
||||
_netuse('A:', r'\\srv\a', 'OK'),
|
||||
_netuse('B:', r'\\srv\b', 'Unavailable'),
|
||||
_netuse('C:', r'\\srv\c', 'Connected'),
|
||||
_netuse('D:', r'\\srv\d', 'Disconnected'),
|
||||
]))
|
||||
ordered = _mapped_drives_unhealthy_first(rows)
|
||||
assert [row['drive'] for row in ordered] == ['B:', 'D:', 'A:', 'C:']
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user