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.
|
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||||
`CONTRIBUTING.md` is the full authority; this is the short version.
|
`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)
|
## Naming (LOCKED - the hook rejects violations)
|
||||||
|
|
||||||
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.
|
- **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
|
## Branches: what belongs on main, and what does not
|
||||||
|
|
||||||
`main` is the curated product other sites adopt, and what the publication
|
`main` is the product every site runs and the publication mirror exports.
|
||||||
mirror exports. It is a release decision: something reaching `main` says other
|
`<site>` branches hold work that is not ready for other sites to see. There are
|
||||||
sites should take it. **`main` is not what this site runs.**
|
two: `wj` here, and `wilm` on the corporate GitHub, where the other site
|
||||||
|
develops the .NET endpoint agent.
|
||||||
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.
|
|
||||||
|
|
||||||
A site branch is not a fork. Both feed `main`, and the sooner they do it the
|
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`:
|
**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
|
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)
|
## Tray app (system-tray icon)
|
||||||
|
|
||||||
The tray is a per-user process, not a service, so it starts at sign-in rather
|
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
|
than under `sc.exe`. The generated `install.ps1` handles it: it copies
|
||||||
`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart
|
`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon
|
||||||
(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and
|
autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`,
|
||||||
launches it for the current user. Run the installer from the operator's own
|
64-bit view). The installer deliberately does NOT start the tray itself: it runs
|
||||||
session so the autostart lands in the right user hive. Pass `-NoTray` for a
|
elevated, so any process it starts inherits an admin token, and mapped drives are
|
||||||
headless, service-only install.
|
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:
|
Right-click the tray icon for:
|
||||||
|
|
||||||
@@ -143,7 +154,7 @@ logic itself changes.
|
|||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log`
|
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log`
|
||||||
(14-day retention).
|
(14-day retention).
|
||||||
- Windows Event Log: agent runtime failures and unhandled exceptions are written
|
- Windows Event Log: agent runtime failures and unhandled exceptions are written
|
||||||
to the `Application` log under source `ShopDB Endpoint Agent`. The source is
|
to the `Application` log under source `ShopDB Endpoint Agent`. The source is
|
||||||
|
|||||||
@@ -89,38 +89,44 @@ public sealed class AgentConfig
|
|||||||
return bare;
|
return bare;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-name allowlist of pre-staged scripts for the generic run_script
|
// REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths.
|
||||||
// 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
|
// This used to be a per-name map of PRE-STAGED scripts on the endpoint
|
||||||
// from this map fails closed. There are no shipped defaults: a script is a
|
// (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which
|
||||||
// site-specific artifact, so an empty map means run_script does nothing
|
// run_script would look up by name and execute. It was superseded by
|
||||||
// until a site stages scripts and lists them in appsettings.json. Keyed by
|
// server-hosted delivery and left behind as dead config: nothing read the
|
||||||
// script name (case-insensitive, .ps1 optional).
|
// map, so it was an execution path that appeared supported but was not.
|
||||||
[JsonPropertyName("scriptpaths")]
|
//
|
||||||
public Dictionary<string, string> ScriptPaths { get; set; } = new();
|
// 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).
|
// Approved scheduled task paths for the scheduledtask_run action. The wire
|
||||||
// Keys are normalized to bare names so a lookup tolerates 'reset' or
|
// carries a free-text task path so the WebUI can expose it as a single
|
||||||
// 'reset.ps1'.
|
// field; ScheduledTaskPolicy validates it against this list and fails closed
|
||||||
public IReadOnlyDictionary<string, string> ResolveScriptPaths()
|
// when it does not match. An entry matches that exact task path, or acts as
|
||||||
{
|
// a folder prefix for anything beneath it.
|
||||||
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
//
|
||||||
foreach (var pair in ScriptPaths)
|
// Empty means "use the shipped Corporate Tools defaults"
|
||||||
{
|
// (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those
|
||||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
// defaults, so a site can narrow the set as well as widen it without an
|
||||||
continue;
|
// agent rebuild. Keep it tight: every entry here is something an operator
|
||||||
resolved[NormalizeScript(pair.Key)] = pair.Value;
|
// holding endpoints.deploy can start, in whatever context the task itself
|
||||||
}
|
// registered.
|
||||||
return resolved;
|
[JsonPropertyName("approvedtaskpaths")]
|
||||||
}
|
public List<string> ApprovedTaskPaths { get; set; } = new();
|
||||||
|
|
||||||
private static string NormalizeScript(string name)
|
|
||||||
{
|
|
||||||
var bare = (name ?? "").Trim();
|
|
||||||
if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
|
|
||||||
bare = bare[..^4];
|
|
||||||
return bare;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsUsable()
|
public bool IsUsable()
|
||||||
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ public sealed class CommandExecutor
|
|||||||
// before they are honored. Local launchPaths always win over a server path.
|
// before they are honored. Local launchPaths always win over a server path.
|
||||||
private readonly LaunchPathPolicy launchPathPolicy;
|
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
|
// Held for update_app: the agent joins InstallPath to BaseUrl and downloads
|
||||||
// the pinned installer with its own collector credential (ApiKey), so the
|
// the pinned installer with its own collector credential (ApiKey), so the
|
||||||
// installer body never rides the command wire.
|
// installer body never rides the command wire.
|
||||||
@@ -77,6 +81,7 @@ public sealed class CommandExecutor
|
|||||||
this.config = config ?? new AgentConfig();
|
this.config = config ?? new AgentConfig();
|
||||||
this.launchPaths = this.config.ResolveLaunchPaths();
|
this.launchPaths = this.config.ResolveLaunchPaths();
|
||||||
this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots);
|
this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots);
|
||||||
|
this.scheduledTaskPolicy = new ScheduledTaskPolicy(this.config.ApprovedTaskPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CommandResult> ExecuteAsync(
|
public async Task<CommandResult> ExecuteAsync(
|
||||||
@@ -116,6 +121,16 @@ public sealed class CommandExecutor
|
|||||||
return RequireTarget(action, target)
|
return RequireTarget(action, target)
|
||||||
?? await ProcessStart(target, token);
|
?? 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
|
// Software delivery. run_script fetches a ShopDB-hosted script
|
||||||
// pinned by SHA-256 and runs it in the script's configured context
|
// pinned by SHA-256 and runs it in the script's configured context
|
||||||
// (system or the interactive user); update_app fetches a ShopDB
|
// (system or the interactive user); update_app fetches a ShopDB
|
||||||
@@ -144,6 +159,14 @@ public sealed class CommandExecutor
|
|||||||
Message = "Fresh telemetry collection requested",
|
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:
|
default:
|
||||||
logger.LogWarning("Rejected unknown command action {Action}", action);
|
logger.LogWarning("Rejected unknown command action {Action}", action);
|
||||||
return new CommandResult
|
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
|
// 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.
|
// failed result to short-circuit, or null when the target is present.
|
||||||
private CommandResult? RequireTarget(string action, string target)
|
private CommandResult? RequireTarget(string action, string target)
|
||||||
@@ -550,6 +665,139 @@ public sealed class CommandExecutor
|
|||||||
return found;
|
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 ----
|
// ---- Software delivery: run_script and update_app ----
|
||||||
|
|
||||||
// Run a ShopDB-hosted script pinned by the server's delivery descriptor. The
|
// 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
|
// '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
|
// console session so the script sees the interactive user's resources (for
|
||||||
// example mapped network drives). Anything missing or mismatched fails closed.
|
// 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(
|
private async Task<CommandResult> RunScript(
|
||||||
string name, ScriptDeliveryClaim? script, CancellationToken token)
|
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
|
// 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
|
// actually ran to a zero exit - not merely that it was launched. Fails closed
|
||||||
// when no interactive user is signed in.
|
// 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(
|
private CommandResult RunScriptInUserSession(
|
||||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||||
{
|
{
|
||||||
@@ -723,7 +997,8 @@ public sealed class CommandExecutor
|
|||||||
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||||
|
|
||||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||||
"powershell.exe", arguments, workingDirectory, waitForExit: true);
|
"powershell.exe", arguments, workingDirectory, waitForExit: true,
|
||||||
|
waitTimeout: UserScriptWaitTimeout);
|
||||||
if (!outcome.Launched)
|
if (!outcome.Launched)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
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 exit = outcome.ExitCode;
|
||||||
var ok = exit is null || exit == 0;
|
var ok = exit is null || exit == 0;
|
||||||
return new CommandResult
|
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,
|
// collection: both Uninstall registry hives, {displayname, displayversion,
|
||||||
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
|
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
|
||||||
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
|
[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
|
public sealed class EndpointEventRecord
|
||||||
{
|
{
|
||||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
[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("eventchannel")] public string? EventChannel { get; set; }
|
||||||
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
||||||
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { 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(55, "System", "NTFS file system corruption"),
|
||||||
new(7011, "System", "Service Control Manager timeout"),
|
new(7011, "System", "Service Control Manager timeout"),
|
||||||
new(10016, "System", "DCOM permission error"),
|
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(1001, "Application", "Application error / bugcheck report"),
|
||||||
|
new(1002, "Application", "Application hang"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The distinct channels the collector must read.
|
// The distinct channels the collector must read.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Eventing.Reader;
|
||||||
using System.Management;
|
using System.Management;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -19,16 +20,28 @@ public sealed class EndpointCollector : ICollector
|
|||||||
// keep the payload bounded on a chatty Security log.
|
// keep the payload bounded on a chatty Security log.
|
||||||
private const int MaxEventsPerChannel = 25;
|
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 ILogger<EndpointCollector> logger;
|
||||||
private readonly AgentConfig config;
|
private readonly AgentConfig config;
|
||||||
private readonly IAgentUtilityRunner? utilities;
|
private readonly IAgentUtilityRunner? utilities;
|
||||||
|
private readonly EventWatermarkStore? watermarks;
|
||||||
|
private readonly UserContextSpool? userContextSpool;
|
||||||
|
|
||||||
|
private object? lastUserContext;
|
||||||
|
|
||||||
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
|
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
|
||||||
IAgentUtilityRunner? utilities = null)
|
IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null,
|
||||||
|
UserContextSpool? userContextSpool = null)
|
||||||
{
|
{
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.utilities = utilities;
|
this.utilities = utilities;
|
||||||
|
this.watermarks = watermarks;
|
||||||
|
this.userContextSpool = userContextSpool;
|
||||||
}
|
}
|
||||||
|
|
||||||
public EndpointReport Collect(string agentVersion, bool adopt)
|
public EndpointReport Collect(string agentVersion, bool adopt)
|
||||||
@@ -49,23 +62,232 @@ public sealed class EndpointCollector : ICollector
|
|||||||
MachineNumber = GetMachineNumber(),
|
MachineNumber = GetMachineNumber(),
|
||||||
LastBootTime = GetLastBootTime(),
|
LastBootTime = GetLastBootTime(),
|
||||||
Services = GetMonitoredTargets(),
|
Services = GetMonitoredTargets(),
|
||||||
|
Ports = GetListeningPorts(),
|
||||||
Interfaces = GetInterfaces(),
|
Interfaces = GetInterfaces(),
|
||||||
Events = GetCuratedEvents(),
|
Events = GetCuratedEvents(),
|
||||||
Disks = GetDisks(),
|
Disks = GetDisks(),
|
||||||
|
DiskHealth = GetDiskHealth(),
|
||||||
Bios = GetBios(),
|
Bios = GetBios(),
|
||||||
Memory = GetMemory(),
|
Memory = GetMemory(),
|
||||||
Processor = GetProcessor(),
|
Processor = GetProcessor(),
|
||||||
OsInfo = GetOsInfo(),
|
OsInfo = GetOsInfo(),
|
||||||
Domain = GetDomain(),
|
Domain = GetDomain(),
|
||||||
Applications = GetApplications(),
|
Applications = GetApplications(),
|
||||||
|
PendingReboot = GetPendingReboot(),
|
||||||
|
RebootReasons = GetRebootReasons(),
|
||||||
|
BootInfo = GetBootInfo(),
|
||||||
|
Defender = GetDefender(),
|
||||||
|
UserContext = GetUserContext(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (report.LastBootTime is DateTimeOffset boot)
|
if (report.LastBootTime is DateTimeOffset boot)
|
||||||
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
|
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
|
||||||
|
|
||||||
|
report.CpuPercent = GetCpuPercent();
|
||||||
|
report.MemoryPercent = GetMemoryPercent();
|
||||||
|
report.IdleSeconds = GetIdleSecondsFromUserContext();
|
||||||
|
|
||||||
return report;
|
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,
|
// 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
|
// 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
|
// tray exe beside the agent first (the normal install layout), then the
|
||||||
@@ -296,6 +518,7 @@ public sealed class EndpointCollector : ICollector
|
|||||||
private object[] GetInterfaces()
|
private object[] GetInterfaces()
|
||||||
{
|
{
|
||||||
var result = new List<object>();
|
var result = new List<object>();
|
||||||
|
var linkstate = GetLinkState();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var searcher = new ManagementObjectSearcher(
|
using var searcher = new ManagementObjectSearcher(
|
||||||
@@ -321,6 +544,10 @@ public sealed class EndpointCollector : ICollector
|
|||||||
var gateway = gateways is null || gateways.Length == 0
|
var gateway = gateways is null || gateways.Length == 0
|
||||||
? null : gateways[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
|
result.Add(new
|
||||||
{
|
{
|
||||||
alias = mo["Description"]?.ToString()?.Trim() ?? "",
|
alias = mo["Description"]?.ToString()?.Trim() ?? "",
|
||||||
@@ -332,6 +559,10 @@ public sealed class EndpointCollector : ICollector
|
|||||||
gateway = gateway ?? "",
|
gateway = gateway ?? "",
|
||||||
subnet = subnet ?? "",
|
subnet = subnet ?? "",
|
||||||
dhcp = mo["DHCPEnabled"] as bool? ?? false,
|
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
|
// Walk each curated channel newest-first and report ONLY records newer than
|
||||||
// that channel, cap per channel. Reads Security/System/Application - the
|
// the stored watermark, so a record crosses the wire exactly once.
|
||||||
// old scaffold read System only and filtered by the wrong IDs.
|
//
|
||||||
|
// 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()
|
private EndpointEventRecord[] GetCuratedEvents()
|
||||||
{
|
{
|
||||||
var records = new List<EndpointEventRecord>();
|
var records = new List<EndpointEventRecord>();
|
||||||
foreach (var channel in CuratedEventCatalog.Channels)
|
foreach (var channel in CuratedEventCatalog.Channels)
|
||||||
{
|
{
|
||||||
var wanted = CuratedEventCatalog.IdsForChannel(channel);
|
var wanted = CuratedEventCatalog.IdsForChannel(channel);
|
||||||
try
|
if (wanted.Count == 0)
|
||||||
{
|
|
||||||
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;
|
continue;
|
||||||
|
|
||||||
records.Add(new EndpointEventRecord
|
try
|
||||||
{
|
{
|
||||||
EventId = id,
|
records.AddRange(ReadChannel(channel, wanted));
|
||||||
EventChannel = channel,
|
|
||||||
EventSource = entry.Source,
|
|
||||||
EventTime = new DateTimeOffset(
|
|
||||||
entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero),
|
|
||||||
EventMessage = Truncate(entry.Message, 1000),
|
|
||||||
});
|
|
||||||
kept++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
@@ -493,9 +717,172 @@ public sealed class EndpointCollector : ICollector
|
|||||||
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
|
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watermarks?.Save();
|
||||||
return records.ToArray();
|
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
|
// Installed-software SBOM. Mirrors the Assess-Orchestrator applications
|
||||||
// collection exactly: enumerate both Uninstall registry hives (64-bit and
|
// collection exactly: enumerate both Uninstall registry hives (64-bit and
|
||||||
// WOW6432Node), keep only entries with a DisplayName, and emit
|
// 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
|
// 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;
|
// running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk;
|
||||||
// network/removable drives are skipped. Bytes stay as longs; the server can
|
// network/removable drives are skipped. Bytes stay as longs; the server can
|
||||||
@@ -591,6 +1215,14 @@ public sealed class EndpointCollector : ICollector
|
|||||||
freebytes = free,
|
freebytes = free,
|
||||||
usedbytes = size is long total && free is long available
|
usedbytes = size is long total && free is long available
|
||||||
? total - available : (long?)null,
|
? 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();
|
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
|
// Firmware identity from Win32_BIOS: vendor, version string, and release
|
||||||
// date. Lets the server track BIOS/UEFI levels for a fleet update campaign.
|
// date. Lets the server track BIOS/UEFI levels for a fleet update campaign.
|
||||||
private object? GetBios()
|
private object? GetBios()
|
||||||
@@ -737,6 +1492,14 @@ public sealed class EndpointCollector : ICollector
|
|||||||
catch { return null; }
|
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
|
// Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC
|
||||||
// string; anything unparseable passes through untouched so nothing is lost.
|
// string; anything unparseable passes through untouched so nothing is lost.
|
||||||
private string? ToBiosDate(string? raw)
|
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 string directory;
|
||||||
private readonly LogLevel minLevel;
|
private readonly LogLevel minLevel;
|
||||||
private readonly int retentionDays;
|
private readonly int retentionDays;
|
||||||
|
private readonly string filePrefix;
|
||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
||||||
private string currentDateStamp = "";
|
private string currentDateStamp = "";
|
||||||
private StreamWriter? writer;
|
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,
|
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
|
||||||
int retentionDays = 14)
|
int retentionDays = 14, string filePrefix = "agent")
|
||||||
{
|
{
|
||||||
this.directory = directory;
|
this.directory = directory;
|
||||||
this.minLevel = minLevel;
|
this.minLevel = minLevel;
|
||||||
this.retentionDays = retentionDays;
|
this.retentionDays = retentionDays;
|
||||||
|
this.filePrefix = filePrefix;
|
||||||
// Best-effort: creating the log directory can fail on a locked-down host
|
// 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
|
// 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
|
// that abort construction; Write() degrades to a no-op if the sink is
|
||||||
// unusable.
|
// 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); }
|
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:
|
// The default log directory when config leaves LogPath empty:
|
||||||
// %ProgramData%\ShopDB\EndpointAgent\logs
|
// %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()
|
public static string DefaultDirectory()
|
||||||
=> Path.Combine(
|
=> Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
"ShopDB", "EndpointAgent", "logs");
|
"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)
|
public ILogger CreateLogger(string categoryName)
|
||||||
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
|
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
|
||||||
|
|
||||||
@@ -85,7 +127,14 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
|||||||
writer?.Flush();
|
writer?.Flush();
|
||||||
writer?.Dispose();
|
writer?.Dispose();
|
||||||
currentDateStamp = stamp;
|
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(
|
writer = new StreamWriter(new FileStream(
|
||||||
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
||||||
{
|
{
|
||||||
@@ -99,7 +148,12 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
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)
|
if (File.GetLastWriteTime(file) < cutoff)
|
||||||
File.Delete(file);
|
File.Delete(file);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace ShopDbEndpointAgent.Core;
|
namespace ShopDbEndpointAgent.Core;
|
||||||
|
|
||||||
@@ -18,7 +19,11 @@ namespace ShopDbEndpointAgent.Core;
|
|||||||
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
|
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
|
||||||
public static class InteractiveProcessLauncher
|
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 TokenDuplicate = 0x0002;
|
||||||
private const int TokenQuery = 0x0008;
|
private const int TokenQuery = 0x0008;
|
||||||
@@ -34,6 +39,10 @@ public static class InteractiveProcessLauncher
|
|||||||
|
|
||||||
private const uint Infinite = 0xFFFFFFFF;
|
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;
|
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
|
||||||
// failures come back as Launched=false with a descriptive Error so the
|
// failures come back as Launched=false with a descriptive Error so the
|
||||||
// caller can surface a clean CommandResult.
|
// 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
|
// needs the actual outcome (run_script in the user session) can report
|
||||||
// completed/failed instead of fire-and-forget "launched". GUI callers
|
// completed/failed instead of fire-and-forget "launched". GUI callers
|
||||||
// (process_start) leave it false and return as soon as the window is up.
|
// (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(
|
public static LaunchOutcome LaunchInActiveSession(
|
||||||
string applicationPath, string? arguments, string? workingDirectory,
|
string applicationPath, string? arguments, string? workingDirectory,
|
||||||
bool waitForExit = false)
|
bool waitForExit = false, TimeSpan? waitTimeout = null)
|
||||||
{
|
{
|
||||||
var sessionId = WTSGetActiveConsoleSessionId();
|
var sessionId = WTSGetActiveConsoleSessionId();
|
||||||
if (sessionId == InvalidSessionId)
|
if (sessionId == InvalidSessionId)
|
||||||
@@ -129,19 +146,33 @@ public static class InteractiveProcessLauncher
|
|||||||
}
|
}
|
||||||
|
|
||||||
int? exitCode = null;
|
int? exitCode = null;
|
||||||
|
bool timedOut = false;
|
||||||
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
|
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
|
||||||
{
|
{
|
||||||
WaitForSingleObject(processInformation.hProcess, Infinite);
|
uint milliseconds = waitTimeout is TimeSpan budget
|
||||||
if (GetExitCodeProcess(processInformation.hProcess, out var code))
|
? (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);
|
exitCode = unchecked((int)code);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (processInformation.hProcess != IntPtr.Zero)
|
if (processInformation.hProcess != IntPtr.Zero)
|
||||||
CloseHandle(processInformation.hProcess);
|
CloseHandle(processInformation.hProcess);
|
||||||
if (processInformation.hThread != IntPtr.Zero)
|
if (processInformation.hThread != IntPtr.Zero)
|
||||||
CloseHandle(processInformation.hThread);
|
CloseHandle(processInformation.hThread);
|
||||||
|
|
||||||
return new LaunchOutcome(true, null, exitCode);
|
return new LaunchOutcome(true, null, exitCode, timedOut);
|
||||||
}
|
}
|
||||||
finally
|
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
|
private enum SecurityImpersonationLevel
|
||||||
{
|
{
|
||||||
SecurityAnonymous,
|
SecurityAnonymous,
|
||||||
@@ -222,6 +304,10 @@ public static class InteractiveProcessLauncher
|
|||||||
[DllImport("userenv.dll", SetLastError = true)]
|
[DllImport("userenv.dll", SetLastError = true)]
|
||||||
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
|
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)]
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
private static extern bool CreateProcessAsUser(
|
private static extern bool CreateProcessAsUser(
|
||||||
IntPtr token,
|
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.Management" Version="8.0.0" />
|
||||||
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
|
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
|
||||||
<PackageReference Include="System.ServiceProcess.ServiceController" 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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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.
|
// cadence tick rather than hammering the server.
|
||||||
public sealed class TelemetryWorker : BackgroundService
|
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 ICollector collector;
|
||||||
private readonly CollectorClient client;
|
private readonly CollectorClient client;
|
||||||
private readonly IAgentUtilityRunner? utilities;
|
private readonly IAgentUtilityRunner? utilities;
|
||||||
@@ -22,11 +27,31 @@ public sealed class TelemetryWorker : BackgroundService
|
|||||||
private readonly string agentVersion;
|
private readonly string agentVersion;
|
||||||
private readonly ILogger<TelemetryWorker> logger;
|
private readonly ILogger<TelemetryWorker> logger;
|
||||||
private readonly CollectSignal? collectSignal;
|
private readonly CollectSignal? collectSignal;
|
||||||
|
private readonly UserContextSpool? userContextSpool;
|
||||||
|
private readonly UserContextRefreshChannel? userContextRefresh;
|
||||||
private readonly Random jitter = new();
|
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,
|
public TelemetryWorker(ICollector collector, CollectorClient client,
|
||||||
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
|
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.collector = collector;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
@@ -35,6 +60,77 @@ public sealed class TelemetryWorker : BackgroundService
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.utilities = utilities;
|
this.utilities = utilities;
|
||||||
this.collectSignal = collectSignal;
|
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)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -111,6 +207,15 @@ public sealed class TelemetryWorker : BackgroundService
|
|||||||
config.Monitors.Processes.Length, config.Monitors.Services.Length);
|
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);
|
var report = collector.Collect(agentVersion, adopt);
|
||||||
|
|
||||||
if (utilities is not null)
|
if (utilities is not null)
|
||||||
@@ -118,6 +223,17 @@ public sealed class TelemetryWorker : BackgroundService
|
|||||||
var extra = utilities.RunAll(token);
|
var extra = utilities.RunAll(token);
|
||||||
if (extra.Count > 0)
|
if (extra.Count > 0)
|
||||||
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
|
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(
|
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");
|
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
|
||||||
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
|
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();
|
||||||
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
|
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
|
||||||
return failures == 0 ? 0 : 1;
|
return failures == 0 ? 0 : 1;
|
||||||
|
|||||||
@@ -16,5 +16,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
|
<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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,11 +8,70 @@
|
|||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
|
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
|
||||||
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
|
<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
|
<!-- 1.0.0: first explicitly stamped tray version. The tray and the agent
|
||||||
service version independently (they are separate exes and can be pushed
|
service version independently (they are separate exes and can be pushed
|
||||||
apart), so the tray carries its own <Version> and the agent reports it
|
apart), so the tray carries its own <Version> and the agent reports it
|
||||||
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
|
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>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
using ShopDbEndpointAgent.Core;
|
using ShopDbEndpointAgent.Core;
|
||||||
|
|
||||||
namespace ShopDbEndpointAgent.Tray;
|
namespace ShopDbEndpointAgent.Tray;
|
||||||
@@ -25,8 +27,83 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
private readonly Icon brandIcon;
|
private readonly Icon brandIcon;
|
||||||
private bool sending;
|
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()
|
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();
|
var menu = new ContextMenuStrip();
|
||||||
menu.Items.Add("Send Status now", null, OnSendStatus);
|
menu.Items.Add("Send Status now", null, OnSendStatus);
|
||||||
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
||||||
@@ -45,6 +122,218 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
ContextMenuStrip = menu,
|
ContextMenuStrip = menu,
|
||||||
};
|
};
|
||||||
trayIcon.DoubleClick += OnSendStatus;
|
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)
|
private async void OnReportIssue(object? sender, EventArgs e)
|
||||||
@@ -169,6 +458,18 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
|
|
||||||
try
|
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");
|
int exitCode = await RunAgentAsync(agentExe, "--once");
|
||||||
if (exitCode == 0)
|
if (exitCode == 0)
|
||||||
ShowBalloon("Send Status", "Status sent to ShopDB.",
|
ShowBalloon("Send Status", "Status sent to ShopDB.",
|
||||||
@@ -249,6 +550,35 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
startInfo.Environment.Remove(key);
|
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),
|
// Same directory first (the installer ships the tray next to the agent),
|
||||||
// then the known Program Files install directory as a fallback.
|
// then the known Program Files install directory as a fallback.
|
||||||
private static string? ResolveAgentExe()
|
private static string? ResolveAgentExe()
|
||||||
@@ -316,6 +646,15 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
{
|
{
|
||||||
if (disposing)
|
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();
|
trayIcon.Dispose();
|
||||||
brandIcon.Dispose();
|
brandIcon.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,110 @@ using ShopDbEndpointAgent.Core;
|
|||||||
|
|
||||||
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
|
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
|
||||||
var forceConsole = args.Any(a => string.Equals(a, "--console", 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 reportPayloadPath = ArgValue(args, "--report");
|
||||||
|
|
||||||
var agentVersion = Assembly.GetExecutingAssembly()
|
var agentVersion = Assembly.GetExecutingAssembly()
|
||||||
.GetName().Version?.ToString() ?? "0.0.0";
|
.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
|
// Bootstrap logger first so config loading (including the registry read that
|
||||||
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
|
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
|
||||||
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
|
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
|
||||||
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
|
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
|
||||||
|
ReportSinkProblem(bootstrapFileLogger, "service");
|
||||||
|
|
||||||
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
|
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
|
||||||
faultMonitor.Install();
|
faultMonitor.Install();
|
||||||
@@ -191,10 +286,37 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
|||||||
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
|
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(
|
services.AddSingleton<ICollector>(sp => new EndpointCollector(
|
||||||
sp.GetRequiredService<ILogger<EndpointCollector>>(),
|
sp.GetRequiredService<ILogger<EndpointCollector>>(),
|
||||||
config,
|
config,
|
||||||
sp.GetService<IAgentUtilityRunner>()));
|
sp.GetService<IAgentUtilityRunner>(),
|
||||||
|
sp.GetRequiredService<EventWatermarkStore>(),
|
||||||
|
sp.GetRequiredService<UserContextSpool>()));
|
||||||
|
|
||||||
services.AddSingleton(sp => new CollectorClient(
|
services.AddSingleton(sp => new CollectorClient(
|
||||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||||
@@ -206,7 +328,9 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
|||||||
agentVersion,
|
agentVersion,
|
||||||
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
|
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
|
||||||
sp.GetService<IAgentUtilityRunner>(),
|
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
|
// Command channel: outbound long-poll for allowlisted WebUI commands, run
|
||||||
// through the fail-closed executor. Hosted alongside telemetry but on its
|
// 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.AddProvider(new FileLoggerProvider(logDirectory));
|
||||||
logging.SetMinimumLevel(LogLevel.Information);
|
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>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||||
<AssemblyName>shopdbagent</AssemblyName>
|
<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)
|
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
|
||||||
instead of %TEMP%, which on locked-down endpoints redirected to another
|
instead of %TEMP%, which on locked-down endpoints redirected to another
|
||||||
user's AppData\Local\Temp and left the SYSTEM-context report upload
|
user's AppData\Local\Temp and left the SYSTEM-context report upload
|
||||||
@@ -50,7 +131,26 @@
|
|||||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
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>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<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>
|
</rewrite>
|
||||||
SHOPDB-CLIENTIP-END -->
|
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>
|
</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.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.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.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,
|
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
|
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 |
|
| series | value | governed by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| product `__version__` | `0.12.0` | ADR-007 |
|
| 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.
|
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 |
|
| chain | head |
|
||||||
|---|---|
|
|---|---|
|
||||||
| core | `7d37_import_batches_and_journal` |
|
| core | `7d41_drop_guest_tools_secrets` |
|
||||||
| backups | `backups0003clearlastseen` |
|
| backups | `backups0003clearlastseen` |
|
||||||
| computers | `computers0004policies` |
|
| computers | `computers0004policies` |
|
||||||
| employees | `employees0002photo` |
|
| employees | `employees0002photo` |
|
||||||
| endpoints | `endpoints0011trayversion` |
|
| endpoints | `endpoints0016dellupdates` |
|
||||||
| geenforce | `geenforce0004minlib` |
|
| geenforce | `geenforce0004minlib` |
|
||||||
| guides | `guides0001baseline` |
|
| guides | `guides0001baseline` |
|
||||||
| knowledgebase | `knowledgebase0001anchor` |
|
| knowledgebase | `knowledgebase0001anchor` |
|
||||||
@@ -39,7 +39,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|
|||||||
| printers | `printers0005observedqueues` |
|
| printers | `printers0005observedqueues` |
|
||||||
| slides | `slides0001anchor` |
|
| slides | `slides0001anchor` |
|
||||||
| tools | `tools0001securesend` |
|
| tools | `tools0001securesend` |
|
||||||
| usb | `usb0003scanfields` |
|
| usb | `usb0004checkinidentity` |
|
||||||
| warranty | `warranty0002proof` |
|
| warranty | `warranty0002proof` |
|
||||||
|
|
||||||
## Bundled plugins (17)
|
## 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-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-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-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
|
## Size
|
||||||
|
|
||||||
- test functions defined: **1653** (parametrised cases collect higher)
|
- test functions defined: **1854** (parametrised cases collect higher)
|
||||||
- documented API paths: **361** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
- documented API paths: **376** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -4034,6 +4034,70 @@
|
|||||||
"params": "query: search (hostname substring)",
|
"params": "query: search (hostname substring)",
|
||||||
"purpose": "List endpoint states. Registered both with and without the trailing slash, so both forms are described here",
|
"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"
|
"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\"}'"
|
"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": "endpoints-siteagent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "core-shopdbbackups"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -18765,9 +18768,8 @@
|
|||||||
"plugin-printers"
|
"plugin-printers"
|
||||||
],
|
],
|
||||||
"summary": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).",
|
"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": [
|
"security": [
|
||||||
{},
|
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
}
|
}
|
||||||
@@ -18788,6 +18790,12 @@
|
|||||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
"$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": {
|
"/api/scripts": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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
|
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. -->
|
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||||
<template v-for="panel in groupedPanels" :key="panel.id">
|
<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
|
<!-- 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
|
panel declares a status rollup, a green/yellow/red dot for the worst
|
||||||
row (a stopped service or a warning/critical event). --><h3
|
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 v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
|
||||||
<div class="pap-item-top">
|
<div class="pap-item-top">
|
||||||
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
|
<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)">
|
:style="colorStyle(mapBadge(panel, item).color)">
|
||||||
{{ mapBadge(panel, item).label }}
|
{{ mapBadge(panel, item).label }}
|
||||||
</span>
|
</span>
|
||||||
@@ -68,6 +81,9 @@
|
|||||||
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
|
||||||
:class="{ mono: m.mono }">{{ m.text }}</span>
|
:class="{ mono: m.mono }">{{ m.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
|
||||||
|
{{ mapBody(panel, item) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
|
||||||
{{ panel.manage.label || 'Manage' }}
|
{{ panel.manage.label || 'Manage' }}
|
||||||
@@ -104,7 +120,42 @@
|
|||||||
@click="setTab(panel, si)"
|
@click="setTab(panel, si)"
|
||||||
>{{ section.label }}</button>
|
>{{ section.label }}</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
|
||||||
:key="fi"
|
:key="fi"
|
||||||
@@ -126,7 +177,10 @@
|
|||||||
|
|
||||||
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
<!-- table: columns declared by the panel (or inferred from row keys) -->
|
||||||
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
|
<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>
|
<thead>
|
||||||
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -136,6 +190,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div v-else class="pap-empty">
|
||||||
|
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- badge: a row of colored badges -->
|
<!-- badge: a row of colored badges -->
|
||||||
@@ -240,9 +297,9 @@ import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
|||||||
import api from '../api'
|
import api from '../api'
|
||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import {
|
import {
|
||||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||||
rowAction, facets, facetedRows, formatValue,
|
rowAction, facets, facetedRows, formatValue, spansFullWidth,
|
||||||
} from './pluginAssetPanels'
|
} from './pluginAssetPanels'
|
||||||
|
|
||||||
// Operator report screenshots are served by a JWT-gated route, so a bare
|
// 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])
|
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({
|
const props = defineProps({
|
||||||
assetid: { type: [Number, String], default: null },
|
assetid: { type: [Number, String], default: null },
|
||||||
// Which detail-page tab to render. Null renders every panel, which is what
|
// 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
|
// the detail pages that have not adopted tabs still do - so this stays
|
||||||
// additive and no existing page changes behaviour.
|
// additive and no existing page changes behaviour.
|
||||||
group: { type: String, default: null },
|
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([])
|
const panels = ref([])
|
||||||
@@ -386,14 +470,19 @@ const panels = ref([])
|
|||||||
// 'actions', this build had no such tab, and the card was simply gone with
|
// '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.
|
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
|
||||||
// Fall back the same way the server does rather than drop it.
|
// 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) {
|
function groupOf(panel) {
|
||||||
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
|
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedPanels = computed(() =>
|
const groupedPanels = computed(() => {
|
||||||
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
|
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
|
// Which groups actually have a panel with data, so a parent can hide a tab
|
||||||
// rather than offering an empty one.
|
// rather than offering an empty one.
|
||||||
@@ -525,6 +614,7 @@ async function load() {
|
|||||||
panels.value = []
|
panels.value = []
|
||||||
revokeReportImageUrls()
|
revokeReportImageUrls()
|
||||||
closeReport()
|
closeReport()
|
||||||
|
bodyOpen.value = {}
|
||||||
if (!props.assetid) return
|
if (!props.assetid) return
|
||||||
let declared
|
let declared
|
||||||
try {
|
try {
|
||||||
@@ -562,6 +652,31 @@ defineExpose({ refresh: load, populatedGroups })
|
|||||||
.pap-title { font-weight: 600; color: var(--text); }
|
.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; }
|
.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); }
|
.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-empty { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
|
|
||||||
.pap-facets {
|
.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)
|
const raw = String(value)
|
||||||
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
|
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'
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
@@ -138,6 +147,14 @@ export function mapMeta(panel, item) {
|
|||||||
.filter(Boolean)
|
.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) {
|
export function manageLink(panel, assetid) {
|
||||||
return panel.manage ? subst(panel.manage.to, assetid) : null
|
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
|
// 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
|
// time, so a panel with 30 values stays the height of its largest section
|
||||||
// instead of dragging one multicol column far past the other.
|
// 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) {
|
export function tabSections(panel) {
|
||||||
const data = panel._data
|
const data = panel._data
|
||||||
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
|
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
|
||||||
return sections
|
return sections
|
||||||
.map((s) => ({
|
.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,
|
label: s.label,
|
||||||
fields: (s.fields || []).map((f) => ({
|
fields: (s.fields || []).map((f) => ({
|
||||||
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
|
||||||
})),
|
})),
|
||||||
}))
|
}
|
||||||
.filter((s) => s.fields.length > 0)
|
})
|
||||||
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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 { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
|
||||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||||
rowAction, facets, facetedRows,
|
rowAction, facets, facetedRows,
|
||||||
} from './pluginAssetPanels'
|
} from './pluginAssetPanels'
|
||||||
@@ -64,7 +64,6 @@ describe('list render mapping (warranty)', () => {
|
|||||||
it('builds the manage link with the assetid', () => {
|
it('builds the manage link with the assetid', () => {
|
||||||
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
|
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('is visible when it has rows or an empty message', () => {
|
it('is visible when it has rows or an empty message', () => {
|
||||||
expect(panelVisible(warrantyPanel)).toBe(true)
|
expect(panelVisible(warrantyPanel)).toBe(true)
|
||||||
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
|
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)', () => {
|
describe('list row filtering (tracked services vs processes)', () => {
|
||||||
const feedPanel = {
|
const feedPanel = {
|
||||||
id: 'endpoints-tracked-services', render: 'list',
|
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',
|
name: 'users',
|
||||||
component: () => import('../../views/settings/UsersList.vue'),
|
component: () => import('../../views/settings/UsersList.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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'
|
import { withBase } from '@/utils/basePath'
|
||||||
|
|
||||||
let settingsCache = null
|
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() {
|
async function loadSettings() {
|
||||||
if (settingsCache) return settingsCache
|
if (settingsCache) return settingsCache
|
||||||
try {
|
if (inFlight) return inFlight
|
||||||
const response = await settingsApi.list()
|
if (retryAfter && Date.now() < retryAfter) return {}
|
||||||
|
inFlight = settingsApi.list()
|
||||||
|
.then((response) => {
|
||||||
const items = response.data?.data || response.data || []
|
const items = response.data?.data || response.data || []
|
||||||
settingsCache = {}
|
const loaded = {}
|
||||||
for (const s of items) settingsCache[s.key] = s.value
|
for (const s of items) loaded[s.key] = s.value
|
||||||
} catch (err) {
|
settingsCache = loaded
|
||||||
console.error('Error loading site settings:', err)
|
retryAfter = 0
|
||||||
settingsCache = {}
|
return loaded
|
||||||
}
|
})
|
||||||
return settingsCache
|
.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 = '') {
|
export async function getSetting(key, fallback = '') {
|
||||||
@@ -132,10 +159,14 @@ export async function getServicenowUrls() {
|
|||||||
// setting instead of waiting for a release.
|
// setting instead of waiting for a release.
|
||||||
//
|
//
|
||||||
// Empty is the default and means "no link" - the id still renders as text.
|
// 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() {
|
export async function getEntraDeviceUrls() {
|
||||||
const entraUrl = await getSetting('entra_device_url', '')
|
const entraUrl = await getSetting('entra_device_url', '')
|
||||||
const intuneUrl = await getSetting('intune_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.
|
// 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>
|
<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="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('/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>
|
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>
|
target="_blank" class="external-link">USB Kiosk</a>
|
||||||
|
|
||||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
<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 { fiscalWeek } from '@/utils/fiscalWeek'
|
||||||
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
|
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
|
||||||
import { withBase } from '../utils/basePath'
|
import { withBase } from '../utils/basePath'
|
||||||
|
import { isPluginEnabled } from '../composables/enabledPlugins'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
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"
|
// 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
|
// test - more reliable than plugin-enabled state, which a copied registry can
|
||||||
// report for a plugin that was never staged.
|
// 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 stagedRoutePaths = new Set(router.getRoutes().map(r => r.path))
|
||||||
const hasRoute = (path) => stagedRoutePaths.has(path)
|
const hasRoute = (path) => stagedRoutePaths.has(path)
|
||||||
// Shopfloor Dashboard is a core view but runs entirely on the notifications
|
// 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.
|
// is empty, so gate the link on the notifications plugin being in this build.
|
||||||
const showShopfloor = hasRoute('/shopfloor')
|
const showShopfloor = hasRoute('/shopfloor')
|
||||||
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||||
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
const showPartsKiosk = hasRoute('/parts-kiosk') && isPluginEnabled('printedparts')
|
||||||
|| hasRoute('/usb-kiosk')
|
const showUsbKiosk = hasRoute('/usb-kiosk') && isPluginEnabled('usb')
|
||||||
|
const showDisplays = showShopfloor || hasRoute('/tv') || showPartsKiosk
|
||||||
|
|| showUsbKiosk
|
||||||
const routeViewKey = computed(() =>
|
const routeViewKey = computed(() =>
|
||||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Site logo <span class="hint">(optional)</span></label>
|
<label>Site logo <span class="hint">(optional)</span></label>
|
||||||
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
<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>
|
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,15 +93,17 @@
|
|||||||
<!-- Floor map -->
|
<!-- Floor map -->
|
||||||
<div v-else-if="current.key === 'map'">
|
<div v-else-if="current.key === 'map'">
|
||||||
<h2>Floor map</h2>
|
<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">
|
<div class="form-group">
|
||||||
<label>Blueprint image (light theme)</label>
|
<label>Blueprint image (light theme)</label>
|
||||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
<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" />
|
<img v-if="blueprintLight" :src="withBase(blueprintLight)" class="wizard-map-thumb" alt="light blueprint" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Blueprint image (dark theme)</label>
|
<label>Blueprint image (dark theme)</label>
|
||||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
<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" />
|
<img v-if="blueprintDark" :src="withBase(blueprintDark)" class="wizard-map-thumb dark" alt="dark blueprint" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -203,14 +206,24 @@ const blueprintLight = ref('')
|
|||||||
const blueprintDark = ref('')
|
const blueprintDark = ref('')
|
||||||
const logoUploading = ref(false)
|
const logoUploading = ref(false)
|
||||||
const siteLogo = ref('')
|
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) {
|
async function uploadSiteLogo(event) {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
logoFilename.value = file.name
|
||||||
logoUploading.value = true
|
logoUploading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
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')
|
toast.success('Logo uploaded')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(apiError(err, 'Upload failed'))
|
toast.error(apiError(err, 'Upload failed'))
|
||||||
@@ -223,10 +236,12 @@ async function uploadSiteLogo(event) {
|
|||||||
async function uploadBlueprint(theme, event) {
|
async function uploadBlueprint(theme, event) {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
if (theme === 'light') blueprintLightFilename.value = file.name
|
||||||
|
else blueprintDarkFilename.value = file.name
|
||||||
mapUploading.value = true
|
mapUploading.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
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
|
if (theme === 'light') blueprintLight.value = url
|
||||||
else blueprintDark.value = url
|
else blueprintDark.value = url
|
||||||
toast.success('Blueprint uploaded')
|
toast.success('Blueprint uploaded')
|
||||||
@@ -307,6 +322,17 @@ onMounted(async () => {
|
|||||||
} catch (err) { /* not set yet */ }
|
} 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() {
|
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
|
// Order: site identity + the reference-data catalogs users touch daily come
|
||||||
// first, then the platform/system groups (integrations, communication, search,
|
// first, then the platform/system groups (integrations, communication, search,
|
||||||
// plugins, access) cluster together at the end.
|
// 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 = [
|
export const settingsGroups = [
|
||||||
{
|
{
|
||||||
@@ -113,6 +113,7 @@ export const settingsGroups = [
|
|||||||
title: 'System',
|
title: 'System',
|
||||||
cards: [
|
cards: [
|
||||||
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
|
{ 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') {
|
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 {
|
} else {
|
||||||
failures.push('PC types')
|
failures.push('PC types')
|
||||||
console.error('Error loading PC types:', types.reason)
|
console.error('Error loading PC types:', types.reason)
|
||||||
|
|||||||
@@ -34,6 +34,13 @@
|
|||||||
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
|
<!-- Identity, then the cloud row beneath it. An earlier cut put the
|
||||||
|
portal block in a fixed-width rail on the right; GUIDs are too
|
||||||
|
long for a narrow column and it wrapped into a tall ragged
|
||||||
|
stack. Full width in the normal flow costs one short row. -->
|
||||||
|
<div class="hero-primary">
|
||||||
|
<div class="hero-headline">
|
||||||
|
<div class="hero-identity">
|
||||||
<div class="hero-title">
|
<div class="hero-title">
|
||||||
<h1>{{ computer.assetnumber }}</h1>
|
<h1>{{ computer.assetnumber }}</h1>
|
||||||
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
||||||
@@ -62,22 +69,117 @@
|
|||||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liveness and the endpoint actions, pulled out of the triage
|
||||||
|
band into the header corner. They are the two things looked at
|
||||||
|
first on a PC that is misbehaving, and at the bottom of the
|
||||||
|
hero they sat below the fold on a laptop. The band keeps the
|
||||||
|
reference facts (serves machine, remote access, location). -->
|
||||||
|
<div class="hero-endpoint">
|
||||||
|
<div v-if="liveness" class="hero-status">
|
||||||
|
<span class="hero-detail-label hero-status-label">Status</span>
|
||||||
|
<span class="triage-chip" :class="liveness.level">
|
||||||
|
<span class="triage-dot"></span>{{ liveness.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="canCommand" class="hero-endpoint-actions">
|
||||||
|
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
|
||||||
|
:disabled="restarting || refreshing">
|
||||||
|
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cloud identity. Laid out as a horizontal row in the same
|
||||||
|
label-over-value language as hero-details, not as a narrow
|
||||||
|
right rail: a 36-character GUID cannot wrap sanely into a
|
||||||
|
300px column, and the ragged stack that produced grew taller
|
||||||
|
than the identity block beside it.
|
||||||
|
Held back until the portal templates resolve so the row does
|
||||||
|
not paint once without its buttons and then again with them. -->
|
||||||
|
<div v-if="hasCloudIdentity" class="hero-portals">
|
||||||
|
<!-- The link sits beside the id it is built from, not in a row
|
||||||
|
of its own: the pairing is counterintuitive - the DEVICE id
|
||||||
|
opens Intune and the OBJECT id opens Entra - and a separate
|
||||||
|
action row would have to re-label which is which. The full
|
||||||
|
id stays selectable because it gets copied into tickets. -->
|
||||||
|
<div class="hero-portal">
|
||||||
|
<span class="hero-portal-label">
|
||||||
|
Device ID
|
||||||
|
<a
|
||||||
|
v-if="intuneDeviceLink"
|
||||||
|
:href="intuneDeviceLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="portal-link"
|
||||||
|
>Intune</a>
|
||||||
|
</span>
|
||||||
|
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
|
||||||
|
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-btn"
|
||||||
|
title="Copy Device ID"
|
||||||
|
aria-label="Copy Device ID"
|
||||||
|
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
|
||||||
|
><Copy :size="14" aria-hidden="true" /></button>
|
||||||
|
</span>
|
||||||
|
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-portal">
|
||||||
|
<span class="hero-portal-label">
|
||||||
|
Object ID
|
||||||
|
<a
|
||||||
|
v-if="entraDeviceLink"
|
||||||
|
:href="entraDeviceLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="portal-link"
|
||||||
|
>Entra</a>
|
||||||
|
<a
|
||||||
|
v-if="lapsDeviceLink"
|
||||||
|
:href="lapsDeviceLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="portal-link"
|
||||||
|
title="Local administrator password (LAPS)"
|
||||||
|
>Azure LAPS</a>
|
||||||
|
</span>
|
||||||
|
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
|
||||||
|
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-btn"
|
||||||
|
title="Copy Object ID"
|
||||||
|
aria-label="Copy Object ID"
|
||||||
|
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
|
||||||
|
><Copy :size="14" aria-hidden="true" /></button>
|
||||||
|
</span>
|
||||||
|
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-portal" v-if="computer.computer?.azuredevicecategory">
|
||||||
|
<span class="hero-portal-label">Category</span>
|
||||||
|
<span class="hero-portal-value">{{ computer.computer.azuredevicecategory }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- The 6am band: is it alive, what stops without it, how do I reach
|
<!-- The 6am band: is it alive, what stops without it, how do I reach
|
||||||
it. "Serves machine" used to sit in the relationships card near
|
it. "Serves machine" used to sit in the relationships card near
|
||||||
the bottom of the page, which is the wrong place for the single
|
the bottom of the page, which is the wrong place for the single
|
||||||
most useful fact about a shop-floor PC. -->
|
most useful fact about a shop-floor PC. -->
|
||||||
|
<!-- Liveness and Refresh Status moved to .hero-endpoint in the header
|
||||||
|
corner. Restart stays here: it is destructive and takes the
|
||||||
|
machine down with it, so it belongs beside the "serves machine"
|
||||||
|
fact that says what stops, not next to a read-only refresh. -->
|
||||||
<AssetTriageBand
|
<AssetTriageBand
|
||||||
:assetid="computer.assetid"
|
:assetid="computer.assetid"
|
||||||
:liveness="liveness"
|
|
||||||
:accessmethods="computer.accessmethods || []"
|
:accessmethods="computer.accessmethods || []"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions v-if="canRestart">
|
||||||
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary btn-sm"
|
<button @click="confirmRestart" class="btn btn-warning btn-sm"
|
||||||
:disabled="restarting || refreshing">
|
|
||||||
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
|
|
||||||
</button>
|
|
||||||
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning btn-sm"
|
|
||||||
:disabled="restarting">Restart Computer</button>
|
:disabled="restarting">Restart Computer</button>
|
||||||
</template>
|
</template>
|
||||||
</AssetTriageBand>
|
</AssetTriageBand>
|
||||||
@@ -96,8 +198,35 @@
|
|||||||
answering for this asset type. -->
|
answering for this asset type. -->
|
||||||
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
|
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
|
||||||
|
|
||||||
<div v-show="activegroup === 'details'" class="content-grid">
|
<!-- Network interfaces is pinned to the top of Details rather than left
|
||||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
|
in System Monitors: which adapter is up and what address it holds is
|
||||||
|
identity, asked at the same moment as "what is this box". It still
|
||||||
|
BELONGS to monitors (declared there, and excluded from that tab
|
||||||
|
below), so there is one declaration and no new group. Full width and
|
||||||
|
outside content-grid because the table has eight columns and would
|
||||||
|
be unreadable in a half-width column. -->
|
||||||
|
<div v-show="activegroup === 'details'" class="detail-full">
|
||||||
|
<PluginAssetPanels
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
:only="['endpoints-interfaces']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activegroup === 'details'" class="content-grid details-flow">
|
||||||
|
<PluginAssetPanels
|
||||||
|
ref="assetPanels"
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
group="details"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mapped drives is lifted the same way as network interfaces: a
|
||||||
|
missing share is a "why can't this operator open their program"
|
||||||
|
question, which gets asked on Details, not under monitors. It
|
||||||
|
stays declared in monitors and is excluded from that tab. -->
|
||||||
|
<PluginAssetPanels
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
:only="['endpoints-mappeddrives']"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
@@ -173,96 +302,28 @@
|
|||||||
now: it is what a responder clicks first, and rendering it twice
|
now: it is what a responder clicks first, and rendering it twice
|
||||||
made the page longer without telling anyone anything. -->
|
made the page longer without telling anyone anything. -->
|
||||||
|
|
||||||
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
|
<!-- The record-checkin Status card lived here (Logged In User, Last
|
||||||
collector), which is a different source from the endpoints
|
Reported, Last Boot), sourced from the asset-report collector
|
||||||
agent's live telemetry panel. Shown only when that record holds
|
rather than the agent. It restated what the Endpoint status panel
|
||||||
something: on a PC with an agent these fields sit empty while
|
already shows from live telemetry, and the two sources phrase the
|
||||||
the endpoint panel is fully populated, so an always-on card
|
same facts differently, so the page appeared to disagree with
|
||||||
reported "Last Reported: Never" next to live telemetry saying
|
itself. The panel is the better source: it is populated by the
|
||||||
otherwise. Two sources disagreeing in public is worse than one
|
agent and carries uptime, versions and drift alongside. -->
|
||||||
source missing. -->
|
|
||||||
<div v-if="hasRecordCheckin" class="section-card">
|
|
||||||
<h3 class="section-title">Status</h3>
|
|
||||||
<div class="info-list">
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Logged In User</span>
|
|
||||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Last Reported</span>
|
|
||||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-row">
|
|
||||||
<span class="info-label">Last Boot</span>
|
|
||||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Entra / Azure device info -->
|
<!-- The Entra Device Info card lived here. Its links moved to the
|
||||||
<div
|
header's right-hand block, which was dead space at every width,
|
||||||
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
|
so they now cost no vertical room and sit beside the identity
|
||||||
class="section-card"
|
they belong to rather than partway down the page. -->
|
||||||
>
|
|
||||||
<h3 class="section-title">Entra Device Info</h3>
|
|
||||||
<div class="info-list">
|
|
||||||
<!-- The link sits beside the id it is built from, not in a row
|
|
||||||
of its own at the top: the pairing is counterintuitive - the
|
|
||||||
DEVICE id opens Intune and the OBJECT id opens Entra - and a
|
|
||||||
separate action row would have to re-label which is which. -->
|
|
||||||
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
|
||||||
<span class="info-label">Device ID</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{{ computer.computer.azuredeviceid }}
|
|
||||||
<a
|
|
||||||
v-if="intuneDeviceLink"
|
|
||||||
:href="intuneDeviceLink"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="portal-link"
|
|
||||||
>Intune</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="computer.computer?.azureobjectid" class="info-row">
|
|
||||||
<span class="info-label">Object ID</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{{ computer.computer.azureobjectid }}
|
|
||||||
<a
|
|
||||||
v-if="entraDeviceLink"
|
|
||||||
:href="entraDeviceLink"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="portal-link"
|
|
||||||
>Entra</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
|
|
||||||
<span class="info-label">Device Category</span>
|
|
||||||
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
<!-- Network -->
|
<!-- The hand-written Network card lived here. It listed current
|
||||||
<div class="section-card">
|
addresses with a Primary badge and nothing else, which undersold
|
||||||
<h3 class="section-title">Network</h3>
|
rows that also carry subnet, gateway, DNS, and their own
|
||||||
<div v-if="computer.communications?.length" class="network-list">
|
first-seen/last-changed stamps. Split into the IP History and IP
|
||||||
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
Configuration panels under System Monitors, which read the same
|
||||||
<div class="network-primary">
|
Communication rows. -->
|
||||||
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
||||||
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
|
||||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
||||||
</div>
|
|
||||||
<div class="network-secondary" v-if="comm.macaddress">
|
|
||||||
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="muted">No network addresses on record</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location & Organization -->
|
<!-- Location & Organization -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
@@ -339,13 +400,65 @@
|
|||||||
feed all belong to the endpoints plugin, and core knows nothing about
|
feed all belong to the endpoints plugin, and core knows nothing about
|
||||||
them.
|
them.
|
||||||
|
|
||||||
Monitors keeps the two-column flow because it is short cards. -->
|
Monitors keeps the two-column flow because it is short cards, and
|
||||||
|
Service/Process Monitors does the same: both are short fact cards
|
||||||
|
rather than the long lists that make Software and Activity go full
|
||||||
|
width. -->
|
||||||
<div v-show="activegroup === 'monitors'" class="content-grid">
|
<div v-show="activegroup === 'monitors'" class="content-grid">
|
||||||
<PluginAssetPanels :assetid="computer.assetid" group="monitors" />
|
<PluginAssetPanels
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
group="monitors"
|
||||||
|
:exclude="['endpoints-interfaces', 'endpoints-mappeddrives']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
|
||||||
|
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
|
||||||
|
</div>
|
||||||
|
<!-- Full width, unlike the two above: the thumbnail grid wants the room,
|
||||||
|
and it is the only card in this tab so there is nothing to flow
|
||||||
|
beside it. -->
|
||||||
|
<div v-show="activegroup === 'reports'" class="detail-full">
|
||||||
|
<PluginAssetPanels :assetid="computer.assetid" group="reports" />
|
||||||
</div>
|
</div>
|
||||||
<div v-show="activegroup === 'activity'" class="detail-full">
|
<div v-show="activegroup === 'activity'" class="detail-full">
|
||||||
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
|
<PluginAssetPanels :assetid="computer.assetid" group="activity" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Corporate Tools: fixed triggers for scheduled tasks already
|
||||||
|
registered on the endpoint. The agent only STARTS a task it already
|
||||||
|
holds and that clears its approved-task allowlist - it never defines
|
||||||
|
one - so the endpoint stays the authority on what actually runs. -->
|
||||||
|
<div v-show="activegroup === 'corptools'" class="detail-full">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Corporate Tools</h3>
|
||||||
|
<p class="corptools-intro">
|
||||||
|
Starts a scheduled task that is already registered on this computer.
|
||||||
|
Each task runs in the security context its own definition specifies.
|
||||||
|
</p>
|
||||||
|
<!-- Persistent by design: not a dismissible toast and not shown only
|
||||||
|
on click. These tasks run real corporate tooling as SYSTEM on a
|
||||||
|
machine someone may be sitting at, and the warning has to still
|
||||||
|
be on screen at the moment an operator decides to press Run. -->
|
||||||
|
<p class="corptools-warning">
|
||||||
|
These actions take effect immediately on the live computer and
|
||||||
|
cannot be undone from here. A Dell update run can reboot the machine
|
||||||
|
and may interrupt the person using it.
|
||||||
|
</p>
|
||||||
|
<div class="corptools-list">
|
||||||
|
<div v-for="tool in corporateTools" :key="tool.task" class="corptools-row">
|
||||||
|
<div class="corptools-text">
|
||||||
|
<div class="corptools-name">{{ tool.label }}</div>
|
||||||
|
<p v-if="tool.warning" class="corptools-rowwarning">{{ tool.warning }}</p>
|
||||||
|
<div class="corptools-desc">{{ tool.description }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-warning btn-sm"
|
||||||
|
:disabled="firingTask === tool.task"
|
||||||
|
@click="confirmRunTask(tool)"
|
||||||
|
>{{ firingTask === tool.task ? 'Starting...' : 'Run' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Audit Footer -->
|
<!-- Audit Footer -->
|
||||||
<div class="audit-footer">
|
<div class="audit-footer">
|
||||||
@@ -361,7 +474,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { colorStyle } from "@/utils/colorStyle"
|
import { colorStyle } from "@/utils/colorStyle"
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||||
@@ -374,6 +487,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
|||||||
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
|
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
|
||||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { Copy } from 'lucide-vue-next'
|
||||||
import { getEntraDeviceUrls } from '@/utils/siteSettings'
|
import { getEntraDeviceUrls } from '@/utils/siteSettings'
|
||||||
import { setRecordTitle } from '@/composables/pageTitle'
|
import { setRecordTitle } from '@/composables/pageTitle'
|
||||||
import { useToast } from '@/composables/toast'
|
import { useToast } from '@/composables/toast'
|
||||||
@@ -401,13 +515,6 @@ const computer = ref(null)
|
|||||||
// shown, which is better than rendering "unknown" and implying a fault.
|
// shown, which is better than rendering "unknown" and implying a fault.
|
||||||
const liveness = ref(null)
|
const liveness = ref(null)
|
||||||
|
|
||||||
// The computer record's own check-in fields, as opposed to live agent
|
|
||||||
// telemetry. Empty on an agent-managed PC, populated where the asset-report
|
|
||||||
// script runs instead.
|
|
||||||
const hasRecordCheckin = computed(() => {
|
|
||||||
const record = computer.value?.computer || {}
|
|
||||||
return Boolean(record.loggedinuser || record.lastreporteddate || record.lastboottime)
|
|
||||||
})
|
|
||||||
const installedApps = ref([])
|
const installedApps = ref([])
|
||||||
const assetPanels = ref(null)
|
const assetPanels = ref(null)
|
||||||
|
|
||||||
@@ -421,6 +528,45 @@ const activegroup = ref('details')
|
|||||||
// blade paths, so guessing one would send people to a dead page.
|
// blade paths, so guessing one would send people to a dead page.
|
||||||
const entraUrlTemplate = ref('')
|
const entraUrlTemplate = ref('')
|
||||||
const intuneUrlTemplate = ref('')
|
const intuneUrlTemplate = ref('')
|
||||||
|
const lapsUrlTemplate = ref('')
|
||||||
|
// The templates arrive from an async settings fetch. Until that settles the
|
||||||
|
// block stays hidden: rendering the ids first and popping the Intune/Entra
|
||||||
|
// buttons in a moment later reads as the buttons being missing, which is
|
||||||
|
// exactly what it looked like before a manual refresh warmed the cache.
|
||||||
|
const portalTemplatesLoaded = ref(false)
|
||||||
|
|
||||||
|
// The settings util negative-caches a failed load for 30s, so an immediate
|
||||||
|
// retry would just re-read the same empty result. Wait past that window.
|
||||||
|
const PORTAL_RETRY_MS = 31000
|
||||||
|
let portalRetryTimer = null
|
||||||
|
|
||||||
|
async function loadPortalTemplates({ allowRetry = true } = {}) {
|
||||||
|
try {
|
||||||
|
const { entraUrl, intuneUrl, lapsUrl, loaded } = await getEntraDeviceUrls()
|
||||||
|
if (!loaded && allowRetry) {
|
||||||
|
// Settings did not load. Leave the block hidden rather than paint a
|
||||||
|
// wrong "not configured" state, and try again once.
|
||||||
|
portalRetryTimer = setTimeout(() => {
|
||||||
|
portalRetryTimer = null
|
||||||
|
loadPortalTemplates({ allowRetry: false })
|
||||||
|
}, PORTAL_RETRY_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entraUrlTemplate.value = entraUrl
|
||||||
|
intuneUrlTemplate.value = intuneUrl
|
||||||
|
lapsUrlTemplate.value = lapsUrl
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore: templates stay empty and the block falls back to whatever else
|
||||||
|
// it has rather than hiding forever.
|
||||||
|
} finally {
|
||||||
|
// Only settle once the answer is trustworthy, or after the retry is spent.
|
||||||
|
if (!portalRetryTimer) portalTemplatesLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (portalRetryTimer) clearTimeout(portalRetryTimer)
|
||||||
|
})
|
||||||
|
|
||||||
function fillTemplate(template, token, value) {
|
function fillTemplate(template, token, value) {
|
||||||
if (!template || !value) return null
|
if (!template || !value) return null
|
||||||
@@ -430,6 +576,33 @@ const entraDeviceLink = computed(() => fillTemplate(
|
|||||||
entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
|
entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
|
||||||
const intuneDeviceLink = computed(() => fillTemplate(
|
const intuneDeviceLink = computed(() => fillTemplate(
|
||||||
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
|
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
|
||||||
|
// LAPS is a blade on the same device as Entra, so it keys off the OBJECT id.
|
||||||
|
const lapsDeviceLink = computed(() => fillTemplate(
|
||||||
|
lapsUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
|
||||||
|
|
||||||
|
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
|
||||||
|
// and rejects when the tab is not focused. Say copy failed and leave the id
|
||||||
|
// selectable rather than reporting a success that did not happen.
|
||||||
|
async function copyId(value, label) {
|
||||||
|
if (!value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
toast.success(`${label} copied.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Copy failed - select the ${label} and copy manually.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether to show the header's cloud block at all.
|
||||||
|
//
|
||||||
|
// Gated on the site having CONFIGURED a portal template, not on this PC having
|
||||||
|
// an id. A site that does not use Intune/Entra has no templates, so the block
|
||||||
|
// is absent entirely rather than permanently asking for an id it will never
|
||||||
|
// get. Where templates exist, a PC missing an id shows which one is missing -
|
||||||
|
// that is the actionable case the empty state was requested for.
|
||||||
|
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||||
|
entraUrlTemplate.value || intuneUrlTemplate.value || lapsUrlTemplate.value
|
||||||
|
|| computer.value?.computer?.azuredevicecategory))
|
||||||
|
|
||||||
// Only offer a tab that has something in it. An empty tab is worse than an
|
// Only offer a tab that has something in it. An empty tab is worse than an
|
||||||
// absent one - it reads as broken rather than as "this site does not collect
|
// absent one - it reads as broken rather than as "this site does not collect
|
||||||
@@ -438,17 +611,40 @@ const intuneDeviceLink = computed(() => fillTemplate(
|
|||||||
// populatedGroups comes from PluginAssetPanels, which knows which panels
|
// populatedGroups comes from PluginAssetPanels, which knows which panels
|
||||||
// actually returned data; Software is additionally true when core has an
|
// actually returned data; Software is additionally true when core has an
|
||||||
// installed-applications list of its own.
|
// installed-applications list of its own.
|
||||||
|
// Gates the Corporate Tools tab. endpoints.deploy currently bundles
|
||||||
|
// scheduledtask_run with run_script/update_app - see the note beside the
|
||||||
|
// permission declaration in plugins/endpoints/plugin.py if that gets split.
|
||||||
|
// Declared ahead of detailtabs because the watch below evaluates that computed
|
||||||
|
// during setup, which reads canDeploy before this line would otherwise run.
|
||||||
|
const canDeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
|
||||||
|
|
||||||
const detailtabs = computed(() => {
|
const detailtabs = computed(() => {
|
||||||
const filled = assetPanels.value?.populatedGroups ?? new Set()
|
const filled = assetPanels.value?.populatedGroups ?? new Set()
|
||||||
const has = (key) => filled.has ? filled.has(key) : false
|
const has = (key) => filled.has ? filled.has(key) : false
|
||||||
const tabs = [{ key: 'details', label: 'Details' }]
|
const tabs = [{ key: 'details', label: 'Details' }]
|
||||||
// Monitors sits second: what is being watched on this box is the question
|
// Monitors sits second: what is being watched on this box is the question
|
||||||
// asked right after "what is it", and well before its software inventory.
|
// asked right after "what is it", and well before its software inventory.
|
||||||
if (has('monitors')) tabs.push({ key: 'monitors', label: 'Monitors' })
|
// Renamed to System Monitors now that service/process watching is its own
|
||||||
|
// tab: this one is the box's own health, that one is what runs on it.
|
||||||
|
if (has('monitors')) tabs.push({ key: 'monitors', label: 'System Monitors' })
|
||||||
|
if (has('serviceprocess')) {
|
||||||
|
tabs.push({ key: 'serviceprocess', label: 'Service/Process Monitors' })
|
||||||
|
}
|
||||||
|
// Operator Reports earns a tab rather than a card in System Monitors: it is
|
||||||
|
// what a person reported, not what the agent measured, and the thumbnail
|
||||||
|
// grid is much bulkier than the fact cards around it.
|
||||||
|
if (has('reports')) tabs.push({ key: 'reports', label: 'Operator Reports' })
|
||||||
if (has('software') || installedApps.value.length > 0) {
|
if (has('software') || installedApps.value.length > 0) {
|
||||||
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
|
tabs.push({ key: 'software', label: 'Software', count: installedApps.value.length || null })
|
||||||
}
|
}
|
||||||
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
|
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
|
||||||
|
// Corporate Tools is permission-gated rather than data-gated: it is a set of
|
||||||
|
// fixed actions, not a rendering of collected telemetry, so there is no
|
||||||
|
// populatedGroup to key off. Hidden without endpoints.deploy because every
|
||||||
|
// button here is an elevated command; the server re-checks regardless.
|
||||||
|
if (canDeploy.value && liveness.value) {
|
||||||
|
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
|
||||||
|
}
|
||||||
return tabs
|
return tabs
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -510,10 +706,12 @@ function relativeAge(value) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Non-critical: a site that has not configured these just gets no link, so a
|
// Non-critical: a site that has not configured these just gets no link, so a
|
||||||
// failure here must not stop the page loading.
|
// failure here must not stop the page loading.
|
||||||
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
|
//
|
||||||
entraUrlTemplate.value = entraUrl
|
// A failed settings fetch is negative-cached for 30s and resolves EMPTY, which
|
||||||
intuneUrlTemplate.value = intuneUrl
|
// is indistinguishable from "not configured" by value alone - the block would
|
||||||
}).catch(() => {})
|
// hide itself and only appear on a later visit that happened to land after the
|
||||||
|
// window. Retry once past that window instead of showing a wrong empty state.
|
||||||
|
loadPortalTemplates()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await computersApi.get(route.params.id)
|
const response = await computersApi.get(route.params.id)
|
||||||
@@ -569,6 +767,12 @@ async function refreshStatus() {
|
|||||||
? await waitForFreshTelemetry(baseline)
|
? await waitForFreshTelemetry(baseline)
|
||||||
: false
|
: false
|
||||||
await assetPanels.value?.refresh()
|
await assetPanels.value?.refresh()
|
||||||
|
// The liveness chip reads its own endpoint, so refreshing the panels alone
|
||||||
|
// leaves it showing the age it was rendered with at page load - the button
|
||||||
|
// looked like it had done nothing. Re-read it whatever the outcome: a
|
||||||
|
// timeout or failure still moves the chip toward quiet, which is the
|
||||||
|
// honest reading.
|
||||||
|
await loadLiveness(computer.value?.assetid)
|
||||||
if (outcome === 'timeout') {
|
if (outcome === 'timeout') {
|
||||||
toast.info('Refresh queued; the endpoint agent may be offline or busy')
|
toast.info('Refresh queued; the endpoint agent may be offline or busy')
|
||||||
} else if (outcome === 'failed') {
|
} else if (outcome === 'failed') {
|
||||||
@@ -651,6 +855,93 @@ async function confirmRestart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed set, deliberately not user-editable from this page: these are the task
|
||||||
|
// paths the agent ships as its approved list (ScheduledTaskPolicy). Adding one
|
||||||
|
// here without adding it to the agent's allowlist gets a clean rejection from
|
||||||
|
// the endpoint rather than an execution, which is the intended failure mode.
|
||||||
|
const corporateTools = [
|
||||||
|
{
|
||||||
|
task: '\\ReportIP',
|
||||||
|
label: 'Report IP',
|
||||||
|
description: 'Re-runs the corporate IP reporting agent on this computer.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task: '\\SFLD-ApplyDSCConfig',
|
||||||
|
label: 'Apply DSC Configuration',
|
||||||
|
description: 'Re-applies the shop floor DSC baseline. Runs as SYSTEM and '
|
||||||
|
+ 'may change system configuration.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task: '\\SFLD - Consume Credentials',
|
||||||
|
label: 'Consume Credentials',
|
||||||
|
description: 'Runs the credential consumption task in the logged-on '
|
||||||
|
+ 'user\'s session. Requires a user to be signed in.',
|
||||||
|
},
|
||||||
|
// Dell Command | Update, split into two entries because they are two
|
||||||
|
// utilities with very different consequences: the scan only reports, the
|
||||||
|
// update installs and may reboot. Keeping them as separate buttons means the
|
||||||
|
// destructive one is never a field edit away from the safe one.
|
||||||
|
{
|
||||||
|
task: '\\ShopDB-DellCommandScan',
|
||||||
|
label: 'Dell Command: Scan',
|
||||||
|
description: 'Scans for available Dell driver and firmware updates. '
|
||||||
|
+ 'Reports only - installs nothing and does not reboot.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
task: '\\ShopDB-DellCommandUpdate',
|
||||||
|
label: 'Dell Command: Apply Updates',
|
||||||
|
// Persistent breakglass warning. Tanium is the primary update utility for
|
||||||
|
// this fleet; this tool exists only for when Tanium has failed. The text is
|
||||||
|
// rendered in the card at all times (not behind a hover or the confirm
|
||||||
|
// dialog) so the operator cannot reach Run without having seen it.
|
||||||
|
warning: 'These updates are meant to be executed by the primary utility - '
|
||||||
|
+ 'Tanium. This tool should only be used in the event of a failed '
|
||||||
|
+ 'update or a problem with Tanium. This is a breakglass update '
|
||||||
|
+ 'utility to account for critical failures in the primary '
|
||||||
|
+ 'management solution.',
|
||||||
|
description: 'Installs available Dell driver and firmware updates. Runs as '
|
||||||
|
+ 'SYSTEM and MAY REBOOT this computer when it finishes.',
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const firingTask = ref('')
|
||||||
|
|
||||||
|
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
|
||||||
|
// confirmRestart - these start real work on a live machine and some of them
|
||||||
|
// change system state - and the server re-checks endpoints.deploy regardless.
|
||||||
|
//
|
||||||
|
// Tools marked destructive get a TYPED confirmation rather than an OK button,
|
||||||
|
// for the same reason Restart has its own gate: applying firmware updates can
|
||||||
|
// reboot a machine that may be mid-cycle on the shop floor, and a misclick
|
||||||
|
// should not be able to do that.
|
||||||
|
async function confirmRunTask(tool) {
|
||||||
|
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||||
|
if (tool.destructive) {
|
||||||
|
const typed = prompt(`${tool.label} on ${label}\n\n`
|
||||||
|
+ `${tool.description}\n\n`
|
||||||
|
+ 'Type RUN to confirm.')
|
||||||
|
if (typed !== 'RUN') return
|
||||||
|
} else if (!confirm(`Run "${tool.label}" on ${label}?\n\n`
|
||||||
|
+ `${tool.description}\n\n`
|
||||||
|
+ 'The task starts immediately on the endpoint.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
firingTask.value = tool.task
|
||||||
|
try {
|
||||||
|
await endpointsApi.enqueueCommand(
|
||||||
|
computer.value.assetid, 'scheduledtask_run', tool.task)
|
||||||
|
// "Started", not "finished": the agent reports success as soon as Task
|
||||||
|
// Scheduler accepts the task, so this never means the work is done. Any
|
||||||
|
// result the task produces comes back through telemetry, not this command.
|
||||||
|
toast.success(`${tool.label} started on ${label}`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(apiError(error, `Failed to queue ${tool.label}`))
|
||||||
|
} finally {
|
||||||
|
firingTask.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Retire, not delete: the server deactivates the asset, so the PC
|
// Retire, not delete: the server deactivates the asset, so the PC
|
||||||
// leaves every list and report while its history and relationships stay put.
|
// leaves every list and report while its history and relationships stay put.
|
||||||
// Reversible by an admin setting isactive back to true.
|
// Reversible by an admin setting isactive back to true.
|
||||||
@@ -701,6 +992,293 @@ async function confirmRetire() {
|
|||||||
}
|
}
|
||||||
.portal-link:hover { border-color: var(--primary); }
|
.portal-link:hover { border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* PC-only header additions. These class names exist nowhere else in the app -
|
||||||
|
Machines, Printers and the rest share only .hero-details/.hero-detail-*
|
||||||
|
from global style.css, which is deliberately left alone. */
|
||||||
|
.hero-primary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
/* Identity on the left, liveness and endpoint actions pinned to the right.
|
||||||
|
.hero-primary is a COLUMN (identity, then the portals row beneath), so the
|
||||||
|
corner block needs its own row here rather than margin-left:auto against
|
||||||
|
the column, which would just stack it under the identity. */
|
||||||
|
.hero-headline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.hero-headline > .hero-identity { flex: 1 1 auto; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-headline { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
.hero-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Horizontal, in the same visual language as .hero-details directly above it,
|
||||||
|
separated by a rule rather than sitting in a box. Wraps to a second line on
|
||||||
|
a narrow window instead of forcing a scrollbar. */
|
||||||
|
.hero-portals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.hero-portal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* The portal button lives on the label line, so it reads as an action on the
|
||||||
|
id below it and adds no height of its own. */
|
||||||
|
.hero-portal-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.hero-portal-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
/* Full id, never truncated: a half-copied GUID looks complete and is worse
|
||||||
|
than none. user-select:all keeps manual selection working as a fallback for
|
||||||
|
when the clipboard API is unavailable over plain HTTP. */
|
||||||
|
.hero-portal-idrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hero-portal-id {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: all;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.2rem;
|
||||||
|
line-height: 0;
|
||||||
|
color: var(--text-light);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Liveness + endpoint actions in the header corner. Pinned to the top right of
|
||||||
|
.hero-primary, which is the flex row holding the identity block. */
|
||||||
|
.hero-endpoint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.hero-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
/* A touch larger than the shared hero label (10px in style.css). Scoped to
|
||||||
|
this one rather than bumping .hero-detail-label, which PC Type / OS /
|
||||||
|
Location also use and which is sized to sit quietly under the identity. */
|
||||||
|
.hero-status-label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.hero-endpoint-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
|
||||||
|
this copy of the markup. Kept in step with that component deliberately. */
|
||||||
|
.triage-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.triage-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
background: var(--text-light);
|
||||||
|
}
|
||||||
|
.triage-chip.ok .triage-dot { background: var(--success); }
|
||||||
|
.triage-chip.warning .triage-dot { background: var(--warning); }
|
||||||
|
|
||||||
|
/* Corporate Tools: a plain action list, not a data table - each row is a
|
||||||
|
trigger with the consequence stated next to it, so the operator reads what
|
||||||
|
it does before the confirm gate rather than after. */
|
||||||
|
.corptools-intro {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
/* Warning weight without alert-banner styling: it is permanent, so a loud
|
||||||
|
dismissible-looking banner would be tuned out within a day. The left rule and
|
||||||
|
warning tint keep it legible as a standing caution. */
|
||||||
|
.corptools-warning {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-left: 3px solid var(--warning, #d97706);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.corptools-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.corptools-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.corptools-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.corptools-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
/* Row-level breakglass notice. Carries the same left-rule treatment as the
|
||||||
|
card-level warning so it reads as a caution and not as body copy, but sits
|
||||||
|
inside the row it applies to. */
|
||||||
|
.corptools-rowwarning {
|
||||||
|
margin: 0.25rem 0 0.375rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border-left: 3px solid var(--danger, #dc2626);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||||
|
.triage-chip.ok { color: var(--success-dark); }
|
||||||
|
.triage-chip.warning { color: var(--warning-dark); }
|
||||||
|
.triage-chip.critical { color: var(--danger); }
|
||||||
|
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
|
||||||
|
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
|
||||||
|
|
||||||
|
/* The Details tab flows its cards instead of sitting on grid rows.
|
||||||
|
Grid pairs items into implicit rows, so a row is as tall as its tallest
|
||||||
|
card and align-items:start leaves the shorter one with dead space beneath
|
||||||
|
it - Warranty is short, Endpoint Status is tall, and Policy has to wait for
|
||||||
|
the taller of the two instead of sliding up. Multicol has no rows, so each
|
||||||
|
card starts where the previous one ended and the holes close.
|
||||||
|
|
||||||
|
Multicol was rejected for .content-grid globally because a column-span:all
|
||||||
|
child (.pap-span, used by table panels) overlaps the shorter column - see
|
||||||
|
the note by .content-grid in style.css. That cannot happen here: this tab's
|
||||||
|
panels are all keyvalue/list renders and spansFullWidth() only returns true
|
||||||
|
for table (or a tabs panel holding one), so nothing in this subtree spans.
|
||||||
|
Monitors and Service/Process DO carry table panels and stay on grid.
|
||||||
|
|
||||||
|
Pure CSS, so there is no measuring, no observer and no reflow cost - the
|
||||||
|
JS-masonry option was declined for exactly that reason.
|
||||||
|
|
||||||
|
The tradeoff multicol brings is reading order: cards run down the left
|
||||||
|
column then down the right, so DOM order still matches visual order here
|
||||||
|
(it is the same top-to-bottom-per-column flow), and column-count keeps
|
||||||
|
focus order intact because no item is visually reordered across columns. */
|
||||||
|
.content-grid.details-flow {
|
||||||
|
display: block;
|
||||||
|
column-count: 2;
|
||||||
|
column-gap: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* display:contents on .content-column would leave the wrappers invisible to
|
||||||
|
multicol as well, but the cards inside them need the break rule, so target
|
||||||
|
the cards themselves wherever they sit. :deep() because the plugin panels
|
||||||
|
are rendered by PluginAssetPanels and carry that component's scope id. */
|
||||||
|
.content-grid.details-flow :deep(.section-card) {
|
||||||
|
break-inside: avoid;
|
||||||
|
/* A card split across the column break would show its header in one column
|
||||||
|
and its rows in the other. */
|
||||||
|
-webkit-column-break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
/* Multicol has no gap between stacked items the way grid does, so the
|
||||||
|
spacing that grid's gap supplied has to come back as a margin. */
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One column below the hero breakpoint, matching .content-grid's own
|
||||||
|
single-column collapse so the two tabs behave the same on a phone. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-grid.details-flow {
|
||||||
|
column-count: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restart is the heaviest action on the page and sat visually equal to the
|
||||||
|
reference chips beside it. ~25% up from .btn-sm (10px / 6px 15px) so the
|
||||||
|
weight matches the consequence. Scoped with :deep() because the button is
|
||||||
|
passed into AssetTriageBand's slot, so the band's scope id lands on it. */
|
||||||
|
:deep(.triage-actions .btn-warning) {
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 7.5px 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below the hero's two-column breakpoint the corner block would strand the
|
||||||
|
buttons against the right edge of a narrow screen. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-endpoint {
|
||||||
|
margin-left: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.hero-status { align-items: flex-start; }
|
||||||
|
.hero-endpoint-actions { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
.copy-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.hero-portal-missing {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero-portals { gap: 1rem 1.5rem; }
|
||||||
|
/* Only here does the id need to break: at this width it cannot fit a line. */
|
||||||
|
.hero-portal-id { white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* PC-specific styles - shared styles are in global style.css */
|
/* PC-specific styles - shared styles are in global style.css */
|
||||||
|
|
||||||
@@ -789,43 +1367,8 @@ async function confirmRetire() {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Network card (IP / MAC list) */
|
/* Network card styles removed with the card itself - IP History and IP
|
||||||
.network-list {
|
Configuration are plugin panels and carry their own table styling. */
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
.network-item {
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.network-primary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.ip-address {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.comm-type {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
|
||||||
.primary-badge {
|
|
||||||
padding: 0.1rem 0.5rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.mac-address {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -573,12 +573,14 @@ onMounted(async () => {
|
|||||||
operatingsystemsApi.list({ perpage: 100 }),
|
operatingsystemsApi.list({ perpage: 100 }),
|
||||||
computersApi.protocols.list(),
|
computersApi.protocols.list(),
|
||||||
businessunitsApi.listAll(),
|
businessunitsApi.listAll(),
|
||||||
// Handles its own failure: a site without printers still gets a form.
|
|
||||||
])
|
])
|
||||||
|
|
||||||
pcTypes.value = ptRes.data.data || []
|
pcTypes.value = ptRes.data.data || []
|
||||||
statuses.value = statusRes.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
|
models.value = allModels
|
||||||
locations.value = locRes || []
|
locations.value = locRes || []
|
||||||
operatingsystems.value = osRes.data.data || []
|
operatingsystems.value = osRes.data.data || []
|
||||||
@@ -636,7 +638,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading data:', 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ onMounted(() => loadData())
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 })
|
const response = await computersApi.types.listAll({ active: false })
|
||||||
pcTypes.value = response.data.data || []
|
pcTypes.value = response || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading PC types:', err)
|
console.error('Error loading PC types:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ schema changes, update this file in the same change.
|
|||||||
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
||||||
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
||||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||||
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
|
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 6). No
|
until the first UI key is generated, then it is switched off (section 7). No
|
||||||
querystring fallback.
|
querystring fallback.
|
||||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||||
report per endpoint.
|
report per endpoint.
|
||||||
@@ -51,9 +51,69 @@ arrays. `adopt` is first-contact only.
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scheduledtasks": [ /* opaque array */ ],
|
"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": [
|
"events": [
|
||||||
{
|
{
|
||||||
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
|
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
|
||||||
|
"eventrecordid": 184412, // Windows record id; dedupe key, see notes
|
||||||
"eventchannel": "System",
|
"eventchannel": "System",
|
||||||
"eventsource": "EventLog",
|
"eventsource": "EventLog",
|
||||||
"eventtime": "2025-01-14T06:11:58Z",
|
"eventtime": "2025-01-14T06:11:58Z",
|
||||||
@@ -81,6 +141,71 @@ Notes:
|
|||||||
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
|
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
|
||||||
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
|
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
|
||||||
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
|
(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
|
## 3. Response
|
||||||
|
|
||||||
@@ -137,12 +262,22 @@ public sealed class EndpointReport
|
|||||||
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
|
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
|
||||||
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
|
||||||
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { 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; }
|
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class EndpointEventRecord
|
public sealed class EndpointEventRecord
|
||||||
{
|
{
|
||||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
||||||
|
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
|
||||||
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
|
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
|
||||||
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
||||||
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
|
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
|
||||||
@@ -163,7 +298,102 @@ public sealed class CollectorResponse
|
|||||||
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
||||||
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
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
|
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
|
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
|
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.
|
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
|
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
|
||||||
`collector.ingest.endpoints`, so rotating or revoking it stops all
|
`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
|
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.
|
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
|
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
|
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
|
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
|
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.
|
the command channel shares the kill switch and adds no second secret.
|
||||||
|
|
||||||
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
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`).
|
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
|
||||||
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
|
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
|
||||||
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
|
- `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)
|
### 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 .routes import endpoints_bp
|
||||||
from . import agentstore
|
from . import agentstore
|
||||||
|
|
||||||
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
|
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||||
# generous ceiling that still refuses an operator who points at a giant wrong
|
# refuses an operator who points at a giant wrong file before it is buffered
|
||||||
# file before it is buffered fully into memory-staged storage.
|
# fully into memory-staged storage.
|
||||||
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
#
|
||||||
|
# 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'])
|
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||||
@@ -69,11 +77,6 @@ def upload_agent_binary(binkey):
|
|||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
'binkey must be one of: agent, tray',
|
'binkey must be one of: agent, tray',
|
||||||
http_code=400)
|
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')
|
upload = request.files.get('file')
|
||||||
if upload is None or not upload.filename:
|
if upload is None or not upload.filename:
|
||||||
@@ -94,6 +97,22 @@ def upload_agent_binary(binkey):
|
|||||||
'uploaded file is not a Windows executable',
|
'uploaded file is not a Windows executable',
|
||||||
http_code=400)
|
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,
|
result = agentstore.store_version(binkey, version, data,
|
||||||
make_current=makecurrent)
|
make_current=makecurrent)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,81 @@ def _format_uptime(seconds):
|
|||||||
return ' '.join(parts)
|
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
|
# Endpoint state
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -419,6 +494,60 @@ def _drift_for_state(state):
|
|||||||
return parsed if isinstance(parsed, list) else []
|
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'])
|
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_status(asset_id: int):
|
def panel_status(asset_id: int):
|
||||||
@@ -432,24 +561,34 @@ def panel_status(asset_id: int):
|
|||||||
if not state:
|
if not state:
|
||||||
return success_response({'fields': []})
|
return success_response({'fields': []})
|
||||||
|
|
||||||
def field(label, value, mono=False):
|
def field(label, value, mono=False, fmt=None):
|
||||||
return {'label': label, 'value': value, 'mono': mono}
|
entry = {'label': label, 'value': value, 'mono': mono}
|
||||||
|
if fmt:
|
||||||
|
entry['format'] = fmt
|
||||||
|
return entry
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
field('Logged in user', state.loggedinuser),
|
field('Logged in user', state.loggedinuser),
|
||||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||||
field('Idle', _format_uptime(state.idleseconds)),
|
field('Idle', _format_uptime(state.idleseconds)),
|
||||||
field('Last boot',
|
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('CPU %', state.cpupercent),
|
||||||
field('Memory %', state.memorypercent),
|
field('Memory %', state.memorypercent),
|
||||||
field('Agent', state.agentversion, mono=True),
|
field('Agent', state.agentversion, mono=True),
|
||||||
field('Tray', state.trayversion, mono=True),
|
field('Tray', state.trayversion, mono=True),
|
||||||
field('Last reported',
|
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, '')]
|
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
|
# 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
|
# 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.
|
# 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})
|
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'])
|
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_telemetry_meta(asset_id: int):
|
def panel_telemetry_meta(asset_id: int):
|
||||||
@@ -723,10 +1348,18 @@ def panel_activity(asset_id: int):
|
|||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for event in events:
|
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({
|
rows.append({
|
||||||
'title': event_title(event.eventid),
|
'title': event_title(event.eventid),
|
||||||
'severity': event_severity(event.eventid),
|
'severity': event_severity(event.eventid),
|
||||||
'detail': 'Windows event {}'.format(event.eventid),
|
'detail': detail,
|
||||||
|
'body': _event_body(event),
|
||||||
'eventtime': event.eventtime,
|
'eventtime': event.eventtime,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -742,7 +1375,9 @@ def panel_activity(asset_id: int):
|
|||||||
'title': '{}{}'.format(label, target),
|
'title': '{}{}'.format(label, target),
|
||||||
# A failed command is the one an operator needs to notice.
|
# A failed command is the one an operator needs to notice.
|
||||||
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
|
'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,
|
'eventtime': command.createdat,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -768,10 +1403,37 @@ def panel_activity(asset_id: int):
|
|||||||
'severity': r['severity'],
|
'severity': r['severity'],
|
||||||
'severitycolor': colours.get(r['severity'], 'gray'),
|
'severitycolor': colours.get(r['severity'], 'gray'),
|
||||||
'detail': r['detail'],
|
'detail': r['detail'],
|
||||||
|
'body': r['body'],
|
||||||
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||||
} for r in rows[:15]])
|
} 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'])
|
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_interfaces(asset_id: int):
|
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
|
# A gatewayless NIC with an address is the machine link; the gateway
|
||||||
# NIC is the corporate LAN.
|
# NIC is the corporate LAN.
|
||||||
'role': 'Machine link' if (ipv4 and not gateway) else '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)
|
return success_response(rows)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ _BASE_APPSETTINGS = {
|
|||||||
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
|
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
|
||||||
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
|
'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)
|
_README = """ShopDB endpoint agent (site build)
|
||||||
@@ -217,7 +223,19 @@ param(
|
|||||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||||
[string] $CertFileName = {certfile},
|
[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"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -362,10 +380,175 @@ if (-not $NoTray) {{
|
|||||||
}}
|
}}
|
||||||
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
||||||
|
|
||||||
# Launch the tray once now so it appears without waiting for the next
|
# Deliberately NOT launched here. This installer runs elevated, so
|
||||||
# logon. Single-instance guard makes a duplicate launch a no-op.
|
# Start-Process would hand the tray an ADMIN token, and drive letters
|
||||||
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
|
# are per logon token: the probe would then enumerate the installing
|
||||||
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
|
# 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)
|
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||||
$trayOk = $false
|
# Report only; never relaunch from here. This block used to Start-Process the
|
||||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
# tray when it was not found running, which would hand it this installer's
|
||||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
|
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
|
||||||
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
|
# above is the only place the tray is started during install, and it starts
|
||||||
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
|
# 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
|
Start-Sleep -Seconds 2
|
||||||
}}
|
}}
|
||||||
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
|
if ($trayFound) {{
|
||||||
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
|
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
|
# 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
|
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) {{
|
if (Test-Path $uninstaller) {{
|
||||||
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
||||||
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
||||||
|
|||||||
@@ -77,12 +77,36 @@ COMMAND_ALLOWLIST = {
|
|||||||
'label': 'Collect now',
|
'label': 'Collect now',
|
||||||
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
|
'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': {
|
'run_script': {
|
||||||
'label': 'Run script',
|
'label': 'Run script',
|
||||||
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
|
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
|
||||||
'fetches the script body by digest using its collector '
|
'fetches the script body by digest using its collector '
|
||||||
'credential and runs it in the script\'s configured '
|
'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',
|
'targetkind': 'script',
|
||||||
},
|
},
|
||||||
'update_app': {
|
'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.
|
# 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
|
# 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.
|
# 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 = {
|
ELEVATED_ACTION_PERMISSIONS = {
|
||||||
'system_restart': 'endpoints.restart',
|
'system_restart': 'endpoints.restart',
|
||||||
|
'scheduledtask_run': 'endpoints.deploy',
|
||||||
'run_script': 'endpoints.deploy',
|
'run_script': 'endpoints.deploy',
|
||||||
'update_app': 'endpoints.deploy',
|
'update_app': 'endpoints.deploy',
|
||||||
}
|
}
|
||||||
@@ -215,6 +304,24 @@ def monitor_id_from_action(action):
|
|||||||
return None
|
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):
|
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
|
||||||
"""The action names an endpoint may run.
|
"""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 -
|
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
|
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
|
(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
|
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away;
|
||||||
of a hard dependency on the computers plugin.
|
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())
|
base = set(COMMAND_ALLOWLIST.keys())
|
||||||
if assetid is None:
|
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
|
# "not permitted by policy". Expand each monitor:<id> to its kind's control
|
||||||
# actions before intersecting.
|
# actions before intersecting.
|
||||||
allowed = allowed | _monitor_control_actions(allowed)
|
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.
|
# Never widen past the code-level allowlist: policy can only gate within it.
|
||||||
return base & allowed
|
return base & allowed
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ EVENT_CATALOG = {
|
|||||||
55: ('File system corruption', 'critical', 'System'),
|
55: ('File system corruption', 'critical', 'System'),
|
||||||
7011: ('Service timeout', 'critical', 'System'),
|
7011: ('Service timeout', 'critical', 'System'),
|
||||||
10016: ('DCOM permission error', 'warning', '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'),
|
1001: ('Application crash', 'critical', 'Application'),
|
||||||
|
1002: ('Application hang', 'warning', 'Application'),
|
||||||
}
|
}
|
||||||
|
|
||||||
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.
|
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.
|
||||||
|
|||||||
@@ -319,9 +319,9 @@
|
|||||||
<div
|
<div
|
||||||
v-for="binary in binaries"
|
v-for="binary in binaries"
|
||||||
:key="binary.key"
|
: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">
|
<p class="field-hint">
|
||||||
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
||||||
binaries available for your "Build Site Agent" install package.
|
binaries available for your "Build Site Agent" install package.
|
||||||
@@ -355,7 +355,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="binary.busy || !binary.version || !binary.file"
|
:disabled="binary.busy || binary.detecting || !binary.file"
|
||||||
@click="uploadBinary(binary.key)"
|
@click="uploadBinary(binary.key)"
|
||||||
>
|
>
|
||||||
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
|
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
|
||||||
@@ -370,8 +370,9 @@
|
|||||||
Upload legacy build for rollback
|
Upload legacy build for rollback
|
||||||
</label>
|
</label>
|
||||||
<p class="field-warning">
|
<p class="field-warning">
|
||||||
WARNING: confirm agent version before uploading from the EXE
|
The version is read from the EXE itself. Leave this blank to use the
|
||||||
properties and enter the version number.
|
binary's own file version, or enter a dotted-numeric override only
|
||||||
|
when the build carries no readable version.
|
||||||
</p>
|
</p>
|
||||||
<div class="binary-upload">
|
<div class="binary-upload">
|
||||||
<input
|
<input
|
||||||
@@ -389,7 +390,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
|
:disabled="binary.busy || !binary.legacyfile"
|
||||||
@click="uploadLegacy(binary.key)"
|
@click="uploadLegacy(binary.key)"
|
||||||
>
|
>
|
||||||
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
|
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
|
||||||
@@ -756,7 +757,8 @@ async function detectVersion(binkey, file) {
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
const response = await api.post(
|
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 || ''
|
return response.data.data?.version || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,7 +791,7 @@ function onLegacyFileChange(binkey, event) {
|
|||||||
|
|
||||||
async function uploadBinary(binkey) {
|
async function uploadBinary(binkey) {
|
||||||
const binary = binaryFor(binkey)
|
const binary = binaryFor(binkey)
|
||||||
if (!binary || !binary.file || !binary.version) return
|
if (!binary || !binary.file) return
|
||||||
binary.busy = true
|
binary.busy = true
|
||||||
binary.message = ''
|
binary.message = ''
|
||||||
binary.error = ''
|
binary.error = ''
|
||||||
@@ -798,7 +800,8 @@ async function uploadBinary(binkey) {
|
|||||||
form.append('version', binary.version)
|
form.append('version', binary.version)
|
||||||
form.append('file', binary.file)
|
form.append('file', binary.file)
|
||||||
const response = await api.post(
|
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 || {}
|
const data = response.data.data || {}
|
||||||
applyBinaryStatus(binkey, data.current, data.versions)
|
applyBinaryStatus(binkey, data.current, data.versions)
|
||||||
binary.version = ''
|
binary.version = ''
|
||||||
@@ -817,7 +820,7 @@ async function uploadBinary(binkey) {
|
|||||||
|
|
||||||
async function uploadLegacy(binkey) {
|
async function uploadLegacy(binkey) {
|
||||||
const binary = binaryFor(binkey)
|
const binary = binaryFor(binkey)
|
||||||
if (!binary || !binary.legacyfile || !binary.legacyversion) return
|
if (!binary || !binary.legacyfile) return
|
||||||
binary.busy = true
|
binary.busy = true
|
||||||
binary.message = ''
|
binary.message = ''
|
||||||
binary.error = ''
|
binary.error = ''
|
||||||
@@ -827,7 +830,8 @@ async function uploadLegacy(binkey) {
|
|||||||
form.append('file', binary.legacyfile)
|
form.append('file', binary.legacyfile)
|
||||||
form.append('makecurrent', 'false')
|
form.append('makecurrent', 'false')
|
||||||
const response = await api.post(
|
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 || {}
|
const data = response.data.data || {}
|
||||||
applyBinaryStatus(binkey, data.current, data.versions)
|
applyBinaryStatus(binkey, data.current, data.versions)
|
||||||
binary.legacyversion = ''
|
binary.legacyversion = ''
|
||||||
@@ -906,10 +910,18 @@ function editConfiguration() {
|
|||||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||||
.info-list li { margin-bottom: 0.5rem; }
|
.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 {
|
.binary-block {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1rem;
|
||||||
padding-top: 1rem;
|
padding: 1rem;
|
||||||
border-top: 1px solid var(--color-border, #333);
|
}
|
||||||
|
.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 {
|
.binary-upload {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
<th>Machine</th>
|
<th>Machine</th>
|
||||||
<th>PC Type</th>
|
<th>PC Type</th>
|
||||||
<th>Health</th>
|
<th>Health</th>
|
||||||
|
<th>Agent Version</th>
|
||||||
<th>Logged-in User</th>
|
<th>Logged-in User</th>
|
||||||
<th>Uptime</th>
|
<th>Uptime</th>
|
||||||
<th>Last Reported</th>
|
<th>Last Reported</th>
|
||||||
@@ -123,12 +124,13 @@
|
|||||||
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
|
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ item.agentversion || '-' }}</td>
|
||||||
<td>{{ item.loggedinuser || '-' }}</td>
|
<td>{{ item.loggedinuser || '-' }}</td>
|
||||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="endpoints.length === 0">
|
<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
|
No endpoints found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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)
|
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||||
scheduledtasksjson = 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
|
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||||
# the endpoint is expected to be running but the latest snapshot does not
|
# the endpoint is expected to be running but the latest snapshot does not
|
||||||
@@ -116,6 +175,8 @@ class EndpointState(db.Model):
|
|||||||
'idleseconds': self.idleseconds,
|
'idleseconds': self.idleseconds,
|
||||||
'cpupercent': self.cpupercent,
|
'cpupercent': self.cpupercent,
|
||||||
'memorypercent': self.memorypercent,
|
'memorypercent': self.memorypercent,
|
||||||
|
'domain': self.domain,
|
||||||
|
'pendingreboot': self.pendingreboot,
|
||||||
'mtconnectrunning': self.mtconnectrunning,
|
'mtconnectrunning': self.mtconnectrunning,
|
||||||
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
|
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
|
||||||
'lastreportedat': _utciso(self.lastreportedat),
|
'lastreportedat': _utciso(self.lastreportedat),
|
||||||
@@ -255,6 +316,16 @@ class EndpointEvent(db.Model):
|
|||||||
eventchannel = db.Column(db.String(50), nullable=True)
|
eventchannel = db.Column(db.String(50), nullable=True)
|
||||||
eventsource = db.Column(db.String(255), 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
|
# When the event happened on the endpoint (from the record), not when it
|
||||||
# reached ShopDB.
|
# reached ShopDB.
|
||||||
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
eventtime = db.Column(db.DateTime, nullable=True, index=True)
|
||||||
@@ -269,6 +340,8 @@ class EndpointEvent(db.Model):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
|
||||||
|
db.Index('idx_endpointevents_assetrecord',
|
||||||
|
'assetid', 'eventchannel', 'eventrecordid', unique=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -279,6 +352,7 @@ class EndpointEvent(db.Model):
|
|||||||
'endpointeventid': self.endpointeventid,
|
'endpointeventid': self.endpointeventid,
|
||||||
'assetid': self.assetid,
|
'assetid': self.assetid,
|
||||||
'eventid': self.eventid,
|
'eventid': self.eventid,
|
||||||
|
'eventrecordid': self.eventrecordid,
|
||||||
'eventchannel': self.eventchannel,
|
'eventchannel': self.eventchannel,
|
||||||
'eventsource': self.eventsource,
|
'eventsource': self.eventsource,
|
||||||
'eventtime': _utciso(self.eventtime),
|
'eventtime': _utciso(self.eventtime),
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
|||||||
'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
|
||||||
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
|
||||||
'scheduledtasks', 'applications', 'events',
|
'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.
|
# remote reboot is a strictly heavier grant than a routine toggle.
|
||||||
('endpoints.restart',
|
('endpoints.restart',
|
||||||
'Restart endpoint computers (destructive)', 'endpoints'),
|
'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
|
# Required IN ADDITION to endpoints.command by the enqueue route, so
|
||||||
# running a staged script or pushing an installer is a strictly
|
# running a staged script or pushing an installer is a strictly
|
||||||
# heavier grant than a routine service/process toggle.
|
# 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',
|
('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
|
# 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
|
# it is not the same job either: the generated ZIP carries a live
|
||||||
# collector key stamped into its install script, so whoever holds
|
# collector key stamped into its install script, so whoever holds
|
||||||
@@ -391,7 +414,9 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'position': 12,
|
'position': 12,
|
||||||
# Service state is what is being watched; the Start/Stop
|
# Service state is what is being watched; the Start/Stop
|
||||||
# controls belong beside it, not in a tab of their own.
|
# 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',
|
'id': 'endpoints-tracked-processes',
|
||||||
@@ -431,7 +456,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'empty': 'No monitored processes reported.',
|
'empty': 'No monitored processes reported.',
|
||||||
'position': 13,
|
'position': 13,
|
||||||
# observed process state, not something you act on here
|
# observed process state, not something you act on here
|
||||||
'group': 'monitors',
|
'group': 'serviceprocess',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
# One feed instead of two cards. Events and commands answer the
|
# One feed instead of two cards. Events and commands answer the
|
||||||
@@ -472,6 +497,11 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
{'key': 'detail'},
|
{'key': 'detail'},
|
||||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
{'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.',
|
'empty': 'Nothing recorded for this endpoint yet.',
|
||||||
'position': 20,
|
'position': 20,
|
||||||
@@ -493,10 +523,46 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
{'key': 'subnet', 'label': 'Subnet'},
|
{'key': 'subnet', 'label': 'Subnet'},
|
||||||
{'key': 'dhcp', 'label': 'Assignment'},
|
{'key': 'dhcp', 'label': 'Assignment'},
|
||||||
{'key': 'role', 'label': 'Role'},
|
{'key': 'role', 'label': 'Role'},
|
||||||
|
{'key': 'link', 'label': 'Link'},
|
||||||
],
|
],
|
||||||
'empty': 'No network interfaces reported.',
|
'empty': 'No network interfaces reported.',
|
||||||
'position': 16,
|
'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',
|
'group': 'monitors',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -524,10 +590,123 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
# the installed-software inventory
|
# the installed-software inventory
|
||||||
'group': 'software',
|
'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
|
# Operator-submitted issue reports: a free-text note and an
|
||||||
# optional screenshot the operator captured from the tray. Sits
|
# optional screenshot the operator captured from the tray. Has
|
||||||
# in the Monitors tab beside the other watch-and-observe panels.
|
# 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
|
# A custom render carries the thumbnail grid and the expanding
|
||||||
# lightbox with left/right cycling the generic modes cannot.
|
# lightbox with left/right cycling the generic modes cannot.
|
||||||
'id': 'endpoints-operator-reports',
|
'id': 'endpoints-operator-reports',
|
||||||
@@ -539,7 +718,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'collapsible': True,
|
'collapsible': True,
|
||||||
'empty': 'No operator reports for this endpoint.',
|
'empty': 'No operator reports for this endpoint.',
|
||||||
'position': 18,
|
'position': 18,
|
||||||
'group': 'monitors',
|
'group': 'reports',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -636,6 +815,80 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'type': 'array',
|
'type': 'array',
|
||||||
'description': 'Scheduled task inventory + last run.',
|
'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': {
|
'applications': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'description': 'Installed-software SBOM from both '
|
'description': 'Installed-software SBOM from both '
|
||||||
@@ -663,6 +916,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'required': ['eventid'],
|
'required': ['eventid'],
|
||||||
'properties': {
|
'properties': {
|
||||||
'eventid': {'type': 'integer'},
|
'eventid': {'type': 'integer'},
|
||||||
|
'eventrecordid': {
|
||||||
|
'type': 'integer',
|
||||||
|
'description': 'Windows event record id; '
|
||||||
|
'unique per channel per '
|
||||||
|
'machine, used to dedup.',
|
||||||
|
},
|
||||||
'eventchannel': {'type': 'string'},
|
'eventchannel': {'type': 'string'},
|
||||||
'eventsource': {'type': 'string'},
|
'eventsource': {'type': 'string'},
|
||||||
'eventtime': {'type': 'string',
|
'eventtime': {'type': 'string',
|
||||||
@@ -846,6 +1105,51 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
if 'scheduledtasks' in payload:
|
if 'scheduledtasks' in payload:
|
||||||
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
|
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
|
# Installed-software SBOM: stored as a per-asset SNAPSHOT in its own
|
||||||
# table (endpointapplications), not a JSON blob on the state, so it can
|
# table (endpointapplications), not a JSON blob on the state, so it can
|
||||||
# later join the applications catalog ("Managed Applications"). Only
|
# later join the applications catalog ("Managed Applications"). Only
|
||||||
@@ -897,6 +1201,19 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
|
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
|
||||||
|
|
||||||
stored_events = 0
|
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 []):
|
for record in (payload.get('events') or []):
|
||||||
try:
|
try:
|
||||||
eventid = int(record.get('eventid'))
|
eventid = int(record.get('eventid'))
|
||||||
@@ -906,10 +1223,45 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
if eventid not in CURATED_EVENT_IDS:
|
if eventid not in CURATED_EVENT_IDS:
|
||||||
# Outside the catalog: ignored on purpose, not an error.
|
# Outside the catalog: ignored on purpose, not an error.
|
||||||
continue
|
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(
|
db.session.add(EndpointEvent(
|
||||||
assetid=assetid,
|
assetid=assetid,
|
||||||
eventid=eventid,
|
eventid=eventid,
|
||||||
eventchannel=record.get('eventchannel'),
|
eventrecordid=eventrecordid,
|
||||||
|
eventchannel=eventchannel,
|
||||||
eventsource=record.get('eventsource'),
|
eventsource=record.get('eventsource'),
|
||||||
eventtime=_parse_dt(record.get('eventtime')),
|
eventtime=_parse_dt(record.get('eventtime')),
|
||||||
eventmessage=record.get('eventmessage'),
|
eventmessage=record.get('eventmessage'),
|
||||||
@@ -917,6 +1269,11 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
))
|
))
|
||||||
stored_events += 1
|
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.
|
# flush, not commit: the collector dispatcher owns the transaction.
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -591,14 +591,14 @@ onMounted(async () => {
|
|||||||
|
|
||||||
machineTypes.value = typesRes.data.data || []
|
machineTypes.value = typesRes.data.data || []
|
||||||
statuses.value = statusRes.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
|
// listAll resolves to the ARRAY, not a response: fetchAllPages already
|
||||||
// unwrapped every page. Reading .data.data off it threw, and the whole
|
// 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
|
// parallel load went to the catch - so every dropdown on this form came up
|
||||||
// empty and the machine's own values never loaded.
|
// empty and the machine's own values never loaded.
|
||||||
|
vendors.value = vendorRes || []
|
||||||
|
locations.value = locRes || []
|
||||||
|
models.value = allModels
|
||||||
|
businessunits.value = buRes || []
|
||||||
pcs.value = pcsRes || []
|
pcs.value = pcsRes || []
|
||||||
|
|
||||||
// Load relationship types separately
|
// Load relationship types separately
|
||||||
|
|||||||
@@ -187,8 +187,11 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
types.value = typesResponse.data.data || []
|
types.value = typesResponse.data.data || []
|
||||||
statuses.value = statusResponse.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 || []
|
locations.value = locResponse || []
|
||||||
businessunits.value = buResponse.data.data || []
|
businessunits.value = buResponse || []
|
||||||
|
|
||||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||||
assettypeid.value = assetType ? assetType.assettypeid : null
|
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=.git
|
||||||
--exclude-dir=versions
|
--exclude-dir=versions
|
||||||
--exclude-dir=staticdocs
|
--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
|
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# uninstall-shopdbagent.ps1
|
# uninstall-shopdbagent.ps1
|
||||||
# ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on
|
# ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on
|
||||||
# the endpoint. Reverses everything install-shopdbagent.ps1 and the site agent
|
# the endpoint. Reverses everything the site agent generator's install.ps1
|
||||||
# generator's install.ps1 create:
|
# creates:
|
||||||
# - stops and deletes the Windows service
|
# - stops and deletes the Windows service
|
||||||
# - kills any running agent/tray processes
|
# - kills any running agent/tray processes
|
||||||
# - removes the tray logon Run key (64-bit and 32-bit views)
|
# - 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 credential hive HKLM\SOFTWARE\GE\ShopDB (both views)
|
||||||
# - removes the Defender path exclusion for the install directory
|
# - removes the Defender path exclusion for the install directory
|
||||||
# - deletes the install directory and the ProgramData data/log tree
|
# - deletes the install directory and the ProgramData data/log tree
|
||||||
@@ -20,6 +21,10 @@ param(
|
|||||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
[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
|
# 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
|
# 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
|
# 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.
|
# 3. Remove the tray logon Run key entry (both views) and the credential hive.
|
||||||
Write-Host "[3/6] Removing registry entries ..."
|
Write-Host "[3/6] Removing registry entries ..."
|
||||||
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
|
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-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
|
||||||
|
|
||||||
# Remove the All-Users Start Menu shortcut the site-agent installer created.
|
# 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
|
# 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
|
# (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.
|
# 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
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# 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 core blueprints
|
||||||
register_blueprints(app)
|
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
|
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
|
||||||
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
# any JWT decode and mint a request-scoped JWT for the token's owner.
|
||||||
from .utils.apitoken_auth import install_apitoken_auth
|
from .utils.apitoken_auth import install_apitoken_auth
|
||||||
@@ -298,6 +319,7 @@ CORE_BLUEPRINT_NAMES = (
|
|||||||
'pluginui',
|
'pluginui',
|
||||||
'apitokens',
|
'apitokens',
|
||||||
'docs',
|
'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
|
# the whole batch can be undone. ImportBatch is the identity approval, write, and
|
||||||
# undo all reference; ImportJournalEntry is one reversible mutation. Exposed so a
|
# undo all reference; ImportJournalEntry is one reversible mutation. Exposed so a
|
||||||
# plugin's import UI can list applied batches and reverse one without reaching
|
# 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 (
|
from shopdb.core.models import (
|
||||||
ImportBatch,
|
ImportBatch,
|
||||||
ImportJournalEntry,
|
ImportJournalEntry,
|
||||||
|
|||||||
@@ -163,11 +163,14 @@ class Config:
|
|||||||
# three different routes, which makes it a property of the app rather than
|
# three different routes, which makes it a property of the app rather than
|
||||||
# of any handler.
|
# of any handler.
|
||||||
#
|
#
|
||||||
# 128 MB clears the largest thing anyone legitimately sends - the agent
|
# 256 MB clears the largest thing anyone legitimately sends - the agent
|
||||||
# binary upload caps itself at 64 MB - with room for the multipart envelope.
|
# 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.
|
# Over this, Werkzeug answers 413 without reading the body.
|
||||||
MAX_CONTENT_LENGTH = int(
|
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):
|
class DevelopmentConfig(Config):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .setup import setup_bp
|
|||||||
from .pluginui import pluginui_bp
|
from .pluginui import pluginui_bp
|
||||||
from .apitokens import apitokens_bp
|
from .apitokens import apitokens_bp
|
||||||
from .docs import docs_bp
|
from .docs import docs_bp
|
||||||
|
from .shopdbbackups import shopdbbackups_bp
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'auth_bp',
|
'auth_bp',
|
||||||
@@ -56,4 +57,5 @@ __all__ = [
|
|||||||
'pluginui_bp',
|
'pluginui_bp',
|
||||||
'apitokens_bp',
|
'apitokens_bp',
|
||||||
'docs_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
|
# 'details' is first and is the fallback: it holds what an asset IS, which is
|
||||||
# what a panel declaring nothing almost always contributes.
|
# 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):
|
def _collect(hookname):
|
||||||
|
|||||||
@@ -1320,6 +1320,21 @@ def build_default_settings():
|
|||||||
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
|
'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',
|
'key': 'servicenow_ticket_prefixes',
|
||||||
'value': 'GEINC,GECHG,GERIT,GESCT',
|
'value': 'GEINC,GECHG,GERIT,GESCT',
|
||||||
@@ -1488,6 +1503,99 @@ def build_default_settings():
|
|||||||
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
||||||
'or json (generic {title,text})'
|
'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),
|
# Audit log retention lives at 'audit_retentiondays' (category site),
|
||||||
# defined above and read by shopdb/core/services/auditretention.py. A
|
# defined above and read by shopdb/core/services/auditretention.py. A
|
||||||
# second key 'audit_retention_days' was seeded here with a different
|
# 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.
|
"""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
|
{"status":"error","data":{"error":{"code","message"}},"meta":{...}}. Three
|
||||||
statuses came straight from Werkzeug as HTML instead:
|
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():
|
def test_there_are_docs_to_check():
|
||||||
"""A path change that silently matched nothing would make this suite pass
|
"""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
|
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():
|
def test_the_map_itself_is_current():
|
||||||
"""The pointer target has to be right, or every document pointing at it is
|
"""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'
|
mapfile = DOCS / 'PROJECT-MAP.md'
|
||||||
assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py'
|
assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py'
|
||||||
text = mapfile.read_text()
|
text = mapfile.read_text()
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
|||||||
# 0010 renames the shipped business policy off the originating site's
|
# 0010 renames the shipped business policy off the originating site's
|
||||||
# designator on installs that already stamped 0007 (ADR-015).
|
# designator on installs that already stamped 0007 (ADR-015).
|
||||||
# 0011 adds the trayversion cache column to endpointstates.
|
# 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
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# 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