Merge origin/wilm into wj
Brings in agent upgrade and log collection features: - One-click agent self-upgrade from endpoint detail page - Auto-register staged agent builds as pushable applications - Log collection and script output capture - Endpoint log collections plugin integration Resolved conflict in ShopDbEndpointAgent.csproj by taking wilm version 1.16.0
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.ServiceProcess;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
@@ -167,6 +169,11 @@ public sealed class CommandExecutor
|
||||
case "network_connections":
|
||||
return NetworkConnections();
|
||||
|
||||
// Read a plaintext log off this machine on demand. Read-only and
|
||||
// bounded: see CollectLog for why each refusal is there.
|
||||
case "collect_log":
|
||||
return RequireTarget(action, target) ?? CollectLog(target);
|
||||
|
||||
default:
|
||||
logger.LogWarning("Rejected unknown command action {Action}", action);
|
||||
return new CommandResult
|
||||
@@ -241,6 +248,248 @@ public sealed class CommandExecutor
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Log collection ----
|
||||
|
||||
// Extensions this agent will read. An allowlist, not a blocklist: the
|
||||
// question is not "is this file dangerous" but "will this render as text",
|
||||
// and only a closed set answers that. Mirrors LOG_PATH_EXTENSIONS on the
|
||||
// server; that copy gives a fast error at enqueue, THIS copy is the one
|
||||
// guarding the disk and must never be removed on the assumption the server
|
||||
// already checked.
|
||||
private static readonly HashSet<string> AllowedLogExtensions =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// The extension the server also omits (see LOG_PATH_EXTENSIONS) is
|
||||
// omitted here too. The two copies must agree, or the server
|
||||
// accepts a path the agent then refuses and the operator gets a
|
||||
// failure with no actionable cause.
|
||||
".log", ".txt", ".xml", ".json", ".csv", ".ini", ".conf",
|
||||
".yml", ".yaml", ".trace", ".out", ".err", ".md",
|
||||
};
|
||||
|
||||
// Ceiling on what crosses the wire. Logs grow without bound; a multi-GB
|
||||
// file would exhaust memory here and be refused by the server's payload
|
||||
// limit anyway, returning nothing. Sending the last 2 MB is far more useful
|
||||
// than failing, because the end of a log is where the recent failure is.
|
||||
private const int MaxLogBytes = 2 * 1024 * 1024;
|
||||
|
||||
// Read a plaintext log file and return its tail.
|
||||
//
|
||||
// Strictly read-only: opens for reading, never creates, writes or deletes.
|
||||
// FileShare.ReadWrite|Delete is essential rather than lax - the interesting
|
||||
// logs are the ones a running application currently holds open, and a
|
||||
// stricter share mode would fail on exactly those with a sharing violation.
|
||||
private CommandResult CollectLog(string target)
|
||||
{
|
||||
var path = (target ?? string.Empty).Trim();
|
||||
|
||||
try
|
||||
{
|
||||
// Re-validate independently of the server. The agent runs as SYSTEM
|
||||
// and this path arrived over the network; trusting an upstream check
|
||||
// would make the whole guard depend on a component this process
|
||||
// cannot verify.
|
||||
var rejection = ValidateLogPath(ref path);
|
||||
if (rejection != null)
|
||||
{
|
||||
logger.LogWarning("collect_log refused {Path}: {Reason}", path, rejection);
|
||||
return new CommandResult { Succeeded = false, Message = rejection };
|
||||
}
|
||||
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"No file found at {path}",
|
||||
};
|
||||
}
|
||||
|
||||
var totalBytes = info.Length;
|
||||
var truncated = totalBytes > MaxLogBytes;
|
||||
|
||||
string content;
|
||||
using (var stream = new FileStream(
|
||||
path, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete))
|
||||
{
|
||||
if (truncated)
|
||||
{
|
||||
// Seek to the tail. This can land mid-line, so the partial
|
||||
// first line is dropped below rather than shown as a
|
||||
// fragment the reader would misread as a real entry.
|
||||
stream.Seek(-MaxLogBytes, SeekOrigin.End);
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, true);
|
||||
content = reader.ReadToEnd();
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
var firstBreak = content.IndexOf('\n');
|
||||
if (firstBreak >= 0 && firstBreak < content.Length - 1)
|
||||
content = content[(firstBreak + 1)..];
|
||||
}
|
||||
|
||||
// A file whose bytes are not text will have produced replacement
|
||||
// characters above. Returning that would fill the operator's pane
|
||||
// with noise, so it is refused with a reason instead.
|
||||
if (LooksBinary(content))
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message =
|
||||
$"{path} does not appear to be a plaintext file; " +
|
||||
"only text logs can be collected",
|
||||
};
|
||||
}
|
||||
|
||||
var lineCount = CountLines(content);
|
||||
|
||||
logger.LogInformation(
|
||||
"collect_log read {Path} ({Bytes} bytes on disk, truncated: {Truncated})",
|
||||
path, totalBytes, truncated);
|
||||
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = truncated
|
||||
? $"Collected the last {content.Length:N0} characters of {info.Name} "
|
||||
+ $"({totalBytes:N0} bytes on disk)"
|
||||
: $"Collected {info.Name} ({totalBytes:N0} bytes)",
|
||||
Detail = new Dictionary<string, object>
|
||||
{
|
||||
// The RESOLVED path, which may differ from what was typed
|
||||
// once environment variables are expanded.
|
||||
["path"] = info.FullName,
|
||||
["content"] = content,
|
||||
["filesizebytes"] = totalBytes,
|
||||
["linecount"] = lineCount,
|
||||
["truncated"] = truncated,
|
||||
["modifiedat"] = info.LastWriteTimeUtc.ToString(
|
||||
"o", CultureInfo.InvariantCulture),
|
||||
},
|
||||
};
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
// Called out separately because the remedy is different from a
|
||||
// generic failure: the file exists but the agent cannot read it.
|
||||
logger.LogWarning(exception, "collect_log denied for {Path}", path);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Access denied reading {path}",
|
||||
};
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "collect_log io failure for {Path}", path);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Could not read {path}: {exception.Message}",
|
||||
};
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "collect_log failed for {Path}", path);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Could not collect {path}: {exception.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Shape validation, run before anything is opened. Returns a reason to
|
||||
// refuse, or null to proceed. Expands environment variables first so the
|
||||
// checks apply to the path that will ACTUALLY be opened - validating the
|
||||
// literal string and then expanding it would let %VAR% smuggle past every
|
||||
// rule here.
|
||||
private static string? ValidateLogPath(ref string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return "A log file path is required";
|
||||
|
||||
path = Environment.ExpandEnvironmentVariables(path).Trim();
|
||||
var normalized = path.Replace('/', '\\');
|
||||
|
||||
if (normalized.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
return "UNC/network paths are not supported";
|
||||
|
||||
// Reject '..' before calling GetFullPath. Normalising first would
|
||||
// resolve the traversal and hide it, so the check has to see the raw
|
||||
// segments.
|
||||
if (normalized.Split('\\').Any(part => part == ".."))
|
||||
return "Path must not contain \"..\" segments";
|
||||
|
||||
if (normalized.Contains('*') || normalized.Contains('?'))
|
||||
return "Wildcards are not supported; name a single log file";
|
||||
|
||||
if (!Path.IsPathRooted(normalized) || normalized.Length < 3
|
||||
|| normalized[1] != ':')
|
||||
{
|
||||
return "Enter a full local path including the drive letter";
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(normalized);
|
||||
if (string.IsNullOrEmpty(extension)
|
||||
|| !AllowedLogExtensions.Contains(extension))
|
||||
{
|
||||
return "Only plaintext log files can be collected "
|
||||
+ $"(allowed: {string.Join(", ", AllowedLogExtensions.OrderBy(e => e))})";
|
||||
}
|
||||
|
||||
// A directory reached through a path that passes every check above is
|
||||
// still not a file, and Directory.Exists is cheap insurance.
|
||||
if (Directory.Exists(normalized))
|
||||
return "That path is a directory, not a log file";
|
||||
|
||||
path = Path.GetFullPath(normalized);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Heuristic: a NUL byte, or many replacement characters, means the decode
|
||||
// produced garbage rather than text. Sampling the head is enough - a binary
|
||||
// file betrays itself immediately, and scanning megabytes to be sure would
|
||||
// cost more than it is worth.
|
||||
private static bool LooksBinary(string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
return false;
|
||||
|
||||
var sample = Math.Min(content.Length, 8000);
|
||||
var replacements = 0;
|
||||
for (var index = 0; index < sample; index++)
|
||||
{
|
||||
var character = content[index];
|
||||
if (character == '\0')
|
||||
return true;
|
||||
if (character == '\uFFFD')
|
||||
replacements++;
|
||||
}
|
||||
|
||||
return replacements > sample / 100;
|
||||
}
|
||||
|
||||
private static int CountLines(string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
return 0;
|
||||
|
||||
var lines = 1;
|
||||
foreach (var character in content)
|
||||
{
|
||||
if (character == '\n')
|
||||
lines++;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private Dictionary<int, string> GetProcessNameSnapshot()
|
||||
{
|
||||
var names = new Dictionary<int, string>();
|
||||
@@ -1016,7 +1265,7 @@ public sealed class CommandExecutor
|
||||
// The endpoint log gets the output ALWAYS, success or failure. This is the
|
||||
// copy that stays on the box: it never leaves the endpoint, so it costs no
|
||||
// exposure, and it is the only record when a script reports success while
|
||||
// doing nothing useful. The server copy is failure-only (see ScriptDetail).
|
||||
// doing nothing useful. The server copy is success-gated (see ShouldReturnOutput).
|
||||
private void LogScriptOutput(string name, string output, int? exitCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
@@ -1166,7 +1415,7 @@ public sealed class CommandExecutor
|
||||
? $"Script {name} completed (system context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
||||
exit == 0 ? null : output),
|
||||
ShouldReturnOutput(script, succeeded: exit == 0) ? output : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1282,10 +1531,22 @@ public sealed class CommandExecutor
|
||||
? $"Script {name} completed (user context)"
|
||||
: $"Script {name} exited with code {exit}",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exit, workingDirectory,
|
||||
ok ? null : output),
|
||||
ShouldReturnOutput(script, succeeded: ok) ? output : null),
|
||||
};
|
||||
}
|
||||
|
||||
// Whether this run's output should travel back to the server.
|
||||
//
|
||||
// A FAILED run always returns it: the whole point of the field is making a
|
||||
// failure diagnosable from the UI rather than by remoting to the box. A
|
||||
// SUCCESSFUL run returns it only when the script's author opted in, because
|
||||
// output carries whatever the body printed - hostnames, share paths, a
|
||||
// token echoed while debugging - and a routine green run is not worth that
|
||||
// exposure by default. A claim from a server too old to send the field
|
||||
// leaves CaptureOutput false, so the behaviour is unchanged.
|
||||
private static bool ShouldReturnOutput(ScriptDeliveryClaim? script, bool succeeded)
|
||||
=> !succeeded || (script?.CaptureOutput ?? false);
|
||||
|
||||
private static Dictionary<string, object> ScriptDetail(
|
||||
string name, ScriptDeliveryClaim? script, string? scriptPath, int? exitCode,
|
||||
string? workingDirectory = null, string? output = null)
|
||||
@@ -1313,12 +1574,12 @@ public sealed class CommandExecutor
|
||||
detail["workingdirectory"] = workingDirectory!;
|
||||
if (exitCode is not null)
|
||||
detail["exitcode"] = exitCode;
|
||||
// FAILURE ONLY. Callers pass output only when the command failed, so a
|
||||
// successful run's stdout never leaves the endpoint. Script output can
|
||||
// carry whatever the body happened to print - hostnames, paths, tokens
|
||||
// an author echoed while debugging - and there is no reason to accept
|
||||
// that exposure for a run nobody needs to diagnose. The endpoint log
|
||||
// keeps the full copy either way.
|
||||
// Always present on failure; on success only when the script opted in
|
||||
// via captureoutput (see ShouldReturnOutput). Script output can carry
|
||||
// whatever the body happened to print - hostnames, paths, tokens an
|
||||
// author echoed while debugging - so a successful run stays silent
|
||||
// unless someone decided otherwise after reading the body. The endpoint
|
||||
// log keeps the full copy either way.
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
detail["output"] = output!;
|
||||
return detail;
|
||||
|
||||
@@ -197,6 +197,13 @@ public sealed class AppDeliveryClaim
|
||||
// written to tolerate a failing cleanup or to probe with a cmdlet that errors on
|
||||
// absence would start aborting halfway. An absent or unrecognised value is
|
||||
// treated as default, so an old server that never sends the field is safe.
|
||||
// CaptureOutput decides whether a SUCCESSFUL run returns its output to the
|
||||
// server. Failures always return it, because a failure has to be diagnosable
|
||||
// without walking to the machine. Success is opt-in per script: output carries
|
||||
// whatever the body printed, and that is not worth persisting server-side for a
|
||||
// routine green run. The endpoint log keeps the full copy regardless, so this
|
||||
// only governs what crosses the wire. Absent means false, which is the same
|
||||
// behaviour as before the field existed.
|
||||
public sealed class ScriptDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||
@@ -206,6 +213,7 @@ public sealed class ScriptDeliveryClaim
|
||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
|
||||
[JsonPropertyName("errormode")] public string? ErrorMode { get; set; }
|
||||
[JsonPropertyName("captureoutput")] public bool CaptureOutput { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
|
||||
@@ -186,38 +186,54 @@
|
||||
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. -->
|
||||
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.5: coordinated release with tray 1.6.6 (no tray changes).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. No tray change - tray
|
||||
stays 1.4.0. -->
|
||||
<!-- 1.14.1: coordinated release with tray 1.6.7 (merged wj 1.14.1).
|
||||
Merge wilm 1.14.0 changes with wj tray coordination releases. -->
|
||||
<!-- 1.14.2: merge wilm fleet alert scope fixes and operator report enhancements.
|
||||
Brings in wilm's alertscope targeting and _alert_targets_endpoint function,
|
||||
along with wj's operator report email improvements (FQDN, machine relationship). -->
|
||||
<Version>1.14.2</Version>
|
||||
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. Supersedes the 1.13.1
|
||||
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
|
||||
code changes. Tray is at 1.6.5 from wj. -->
|
||||
<!-- 1.15.0: successful runs can now return their output to the server, gated
|
||||
by a per-script captureoutput flag on the claim. Failures still always
|
||||
return output; success previously never did, which left a green badge as
|
||||
the only evidence a run produced and no way to tell "did the work" from
|
||||
"PowerShell exited cleanly". Opt-in rather than always-on because output
|
||||
carries whatever the body printed, and that is not worth persisting
|
||||
server-side for every routine run. Minor because the claim gains a field
|
||||
that defaults false: an agent or server too old to know it keeps today's
|
||||
withholding behaviour, which is the safe direction to fail. -->
|
||||
<!-- 1.16.0: adds collect_log, which reads a plaintext log file from a
|
||||
caller-supplied path and returns its contents for the Log Collector tab.
|
||||
Strictly read-only: opened for reading with FileShare.ReadWrite|Delete,
|
||||
because the logs worth collecting are the ones a running application
|
||||
currently holds open and a stricter share mode fails on exactly those.
|
||||
Bounded rather than unlimited - only the last 2 MB is returned, and only
|
||||
for an allowlisted set of text extensions; a binary or oversized file is
|
||||
refused rather than mangled into an unreadable pane. The path is
|
||||
re-validated here even though the server already checked it, since this
|
||||
process runs as SYSTEM and the path arrived over the network: environment
|
||||
variables are expanded BEFORE the checks so %VAR% cannot smuggle a
|
||||
traversal or UNC path past them. Minor because it is a new action name -
|
||||
an older agent asked for it fails closed on the unknown-action default. -->
|
||||
<Version>1.16.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -228,9 +228,44 @@ export const endpointsApi = {
|
||||
}
|
||||
return api.post(`/endpoints/${assetId}/commands`, body)
|
||||
},
|
||||
// Pushes the published agent build to one endpoint. Separate from
|
||||
// enqueueCommand because the server prepares the delivery (packages the
|
||||
// build, seeds the upgrade script) before queueing it as a pinned
|
||||
// run_script; the caller has no script id to pass and must not invent one.
|
||||
enqueueAgentUpgrade(assetId, opts = {}) {
|
||||
const body = {}
|
||||
if (opts.policyoverride) {
|
||||
body.policyoverride = true
|
||||
body.overridereason = opts.overridereason
|
||||
}
|
||||
return api.post(`/endpoints/${assetId}/agentupgrade`, body)
|
||||
},
|
||||
listCommands(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/commands`)
|
||||
},
|
||||
// Recent run_script commands only, with the agent-reported detail the plain
|
||||
// command list omits. Feeds the run history under the Run Scripts tab.
|
||||
listScriptRuns(assetId, limit = 5) {
|
||||
return api.get(`/endpoints/${assetId}/scriptruns`, { params: { limit } })
|
||||
},
|
||||
// Collected-log history for one endpoint. Metadata only - the log bodies are
|
||||
// fetched one at a time, because a 20-row history of multi-hundred-KB logs
|
||||
// would make opening the tab far more expensive than reading the one log the
|
||||
// operator actually wants.
|
||||
listLogCollections(assetId) {
|
||||
return api.get(`/endpoints/${assetId}/logcollections`)
|
||||
},
|
||||
// One collected log including its content, for the scrollable pane.
|
||||
getLogCollection(collectionId) {
|
||||
return api.get(`/endpoints/logcollections/${collectionId}`)
|
||||
},
|
||||
// Blob, not JSON: the server sends this as a text/plain attachment, and the
|
||||
// caller turns it into a save dialog.
|
||||
downloadLogCollection(collectionId) {
|
||||
return api.get(`/endpoints/logcollections/${collectionId}/download`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
// Active break-glass holds. Readable by any endpoints.view holder, since a
|
||||
// suppressed action is drift an operator must be able to explain.
|
||||
listPolicyHolds(assetId) {
|
||||
|
||||
@@ -309,8 +309,13 @@ import {
|
||||
// unmount so the blobs are not leaked.
|
||||
const reportImageUrls = ref({})
|
||||
|
||||
// Operator reports carry a full timestamp, and the time of day is the useful
|
||||
// part: several reports from one shift all landed on the same date, so a
|
||||
// date-only label made them indistinguishable and un-orderable. formatValue's
|
||||
// 'date' mode renders via toLocaleDateString and discards the clock, so this
|
||||
// asks for 'datetime'.
|
||||
function formatDate(value) {
|
||||
return formatValue(value, { format: 'date' })
|
||||
return formatValue(value, { format: 'datetime' })
|
||||
}
|
||||
|
||||
// Called from the template, so it runs on EVERY render. Without the seen-set it
|
||||
|
||||
48
migrations/versions/7d50_script_captureoutput.py
Normal file
48
migrations/versions/7d50_script_captureoutput.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Add scripts captureoutput
|
||||
|
||||
Backs the per-script "may a successful run send its output back" field.
|
||||
|
||||
The agent already captures a script's full output and always writes it to the
|
||||
endpoint log. What this controls is narrower: whether that output also crosses
|
||||
to the server and is persisted in the command's resultjson, where the Run
|
||||
Scripts history can show it.
|
||||
|
||||
Failures have always sent output and still do - a failed run has to be
|
||||
diagnosable without walking to the machine. Success is the case in question,
|
||||
and it is opt-in because output carries whatever the body happened to print:
|
||||
hostnames, share paths, an account name, a token an author echoed while
|
||||
debugging. Accepting that exposure for every routine green run is a poor
|
||||
trade, so the author decides per script, after reading the body.
|
||||
|
||||
BOOLEAN NOT NULL defaulting FALSE, so every script row written before this
|
||||
migration keeps exactly today's behaviour: metadata on success, output only on
|
||||
failure. Same rule as errormode - never retroactive, because a library-wide
|
||||
flip would start persisting output from bodies nobody re-read with this
|
||||
question in mind.
|
||||
|
||||
Revision ID: 7d50_script_captureoutput
|
||||
Revises: 7d49_script_errormode
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d50_script_captureoutput'
|
||||
down_revision = '7d49_script_errormode'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('scripts', sa.Column(
|
||||
'captureoutput', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false(),
|
||||
comment='When true, a successful run also returns its output to the '
|
||||
'server; failures always do. Off by default because script '
|
||||
'output can contain whatever the body printed.'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('scripts', 'captureoutput')
|
||||
92
migrations/versions/7d51_endpoint_logcollections.py
Normal file
92
migrations/versions/7d51_endpoint_logcollections.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Add endpointlogcollections
|
||||
|
||||
Backs the manual Log Collector tab: one row per plaintext log file an operator
|
||||
collected from an endpoint by path.
|
||||
|
||||
Collected content gets its own table rather than living in
|
||||
endpointcommands.resultjson for three reasons. It is bulky, and resultjson is
|
||||
read by every command-history list that only wants a status. It needs its own
|
||||
retention rule, which a command row cannot carry without deleting the audit
|
||||
record of the command itself. And the download route has to address a single
|
||||
collection by id and serve it as a file, which is awkward against a JSON column.
|
||||
|
||||
Retention is PER ASSET (20, enforced on insert in the result route), not global.
|
||||
The history renders on a single machine's detail page, so a global cap would let
|
||||
one busy endpoint quietly evict the history of another - the panel would be
|
||||
unreliable exactly where it is read.
|
||||
|
||||
endpointcommandid is SET NULL rather than CASCADE: commands are reaped and
|
||||
expired over time, and a collected log an operator is still reading must not
|
||||
vanish because its originating command row was cleaned up. assetid stays CASCADE
|
||||
because a log has no meaning once the machine is gone.
|
||||
|
||||
filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on
|
||||
disk may be gigabytes, while what we store is a bounded tail.
|
||||
|
||||
Revision ID: 7d51_endpoint_logcollections
|
||||
Revises: 7d50_script_captureoutput
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d51_endpoint_logcollections'
|
||||
down_revision = '7d50_script_captureoutput'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'endpointlogcollections',
|
||||
sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('endpointcommandid', sa.Integer(), nullable=True),
|
||||
sa.Column('sourcepath', sa.String(length=512), nullable=False,
|
||||
comment='Path as the AGENT resolved it, which may differ '
|
||||
'from what the operator typed once environment '
|
||||
'variables are expanded endpoint-side.'),
|
||||
sa.Column('content', sa.Text(length=16777215), nullable=True),
|
||||
sa.Column('filesizebytes', sa.BigInteger(), nullable=True,
|
||||
comment='Size of the file on disk. Differs from contentbytes '
|
||||
'when the stored copy is a truncated tail.'),
|
||||
sa.Column('contentbytes', sa.Integer(), nullable=True),
|
||||
sa.Column('linecount', sa.Integer(), nullable=True),
|
||||
sa.Column('istruncated', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false(),
|
||||
comment='True when content is only the tail of a larger file.'),
|
||||
sa.Column('filemodifiedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('collectedbyuserid', sa.Integer(), nullable=True),
|
||||
sa.Column('collectedbyusername', sa.String(length=100), nullable=True,
|
||||
comment='Username snapshot; the id alone becomes unreadable '
|
||||
'if the user is later removed.'),
|
||||
sa.Column('collectedat', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['endpointcommandid'],
|
||||
['endpointcommands.endpointcommandid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'],
|
||||
ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('endpointlogcollectionid'),
|
||||
)
|
||||
op.create_index('ix_endpointlogcollections_assetid',
|
||||
'endpointlogcollections', ['assetid'])
|
||||
op.create_index('ix_endpointlogcollections_collectedat',
|
||||
'endpointlogcollections', ['collectedat'])
|
||||
# Serves both hot paths: listing an asset's history newest-first, and
|
||||
# finding that asset's oldest rows to prune on insert.
|
||||
op.create_index('idx_endpointlogcollections_assetcollected',
|
||||
'endpointlogcollections', ['assetid', 'collectedat'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_endpointlogcollections_assetcollected',
|
||||
table_name='endpointlogcollections')
|
||||
op.drop_index('ix_endpointlogcollections_collectedat',
|
||||
table_name='endpointlogcollections')
|
||||
op.drop_index('ix_endpointlogcollections_assetid',
|
||||
table_name='endpointlogcollections')
|
||||
op.drop_table('endpointlogcollections')
|
||||
@@ -218,7 +218,7 @@
|
||||
:assetid="computer.assetid"
|
||||
:accessmethods="computer.accessmethods || []"
|
||||
>
|
||||
<template #actions v-if="canRestart || canHoldPolicy">
|
||||
<template #actions v-if="canRestart || canHoldPolicy || (canAgentUpdate && agentBehind)">
|
||||
<!-- Admin only, and FIRST in the band. Placing a hold switches OFF
|
||||
automatic policy enforcement, so it sits with the destructive
|
||||
controls rather than with the read-only refresh. Red rather
|
||||
@@ -233,6 +233,18 @@
|
||||
<button v-if="canRestart" @click="confirmRestart"
|
||||
class="btn btn-warning btn-sm"
|
||||
:disabled="restarting">Restart Computer</button>
|
||||
<!-- Only shown when this endpoint is actually behind the
|
||||
published build, so it is a call to action rather than a
|
||||
permanent button. Warning-styled: it interrupts the agent
|
||||
service, but unlike Restart it does not take the machine
|
||||
down, and it rolls back on failure. -->
|
||||
<button v-if="canAgentUpdate && agentBehind"
|
||||
@click="confirmAgentUpgrade"
|
||||
class="btn btn-warning btn-sm"
|
||||
:title="`Endpoint is on ${agentversion}, published build is ${agentcurrent}`"
|
||||
:disabled="upgradingAgent">
|
||||
{{ upgradingAgent ? 'Queueing...' : `Update Agent to ${agentcurrent}` }}
|
||||
</button>
|
||||
</template>
|
||||
</AssetTriageBand>
|
||||
</div>
|
||||
@@ -594,6 +606,238 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Recent runs. A script that exits zero shows a green badge and
|
||||
nothing else, which is not enough to tell "it did the work" from
|
||||
"PowerShell started and stopped cleanly". The exit code, the
|
||||
context it ran as and the directory it really ran in are shown so
|
||||
the operator can judge that themselves. -->
|
||||
<div class="card">
|
||||
<h3>Recent Script Runs</h3>
|
||||
<div v-if="scriptrunsloading" class="runscripts-empty">
|
||||
Loading recent runs...
|
||||
</div>
|
||||
<div v-else-if="scriptruns.length === 0" class="runscripts-empty">
|
||||
No scripts have been run on this computer yet.
|
||||
</div>
|
||||
<ul v-else class="scriptrun-list">
|
||||
<li
|
||||
v-for="run in scriptruns"
|
||||
:key="run.endpointcommandid"
|
||||
class="scriptrun-row"
|
||||
>
|
||||
<div class="scriptrun-head">
|
||||
<span
|
||||
class="scriptrun-badge"
|
||||
:class="`scriptrun-badge--${run.status}`"
|
||||
>{{ scriptRunStatusLabel(run) }}</span>
|
||||
<span class="scriptrun-name">
|
||||
{{ run.scriptname || run.target }}
|
||||
</span>
|
||||
<span class="scriptrun-when" :title="scriptRunTimeTitle(run)">
|
||||
{{ scriptRunTimeLabel(run) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="run.resultmessage" class="scriptrun-message">
|
||||
{{ run.resultmessage }}
|
||||
</p>
|
||||
|
||||
<dl v-if="run.resultdetail" class="scriptrun-facts">
|
||||
<template v-if="run.resultdetail.exitcode !== undefined
|
||||
&& run.resultdetail.exitcode !== null">
|
||||
<dt>Exit code</dt>
|
||||
<dd class="mono">{{ run.resultdetail.exitcode }}</dd>
|
||||
</template>
|
||||
<template v-if="run.resultdetail.runcontext">
|
||||
<dt>Ran as</dt>
|
||||
<dd>
|
||||
{{ run.resultdetail.runcontext === 'user'
|
||||
? 'The logged-on user' : 'SYSTEM' }}
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="run.resultdetail.workingdirectory">
|
||||
<dt>Ran in</dt>
|
||||
<dd class="mono">{{ run.resultdetail.workingdirectory }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<!-- Output is only reported back on failure; on success it stays
|
||||
on the endpoint. Shown verbatim when present because it is
|
||||
the thing that explains the failure. -->
|
||||
<pre
|
||||
v-if="run.resultdetail && run.resultdetail.output"
|
||||
class="scriptrun-output"
|
||||
>{{ run.resultdetail.output }}</pre>
|
||||
|
||||
<p
|
||||
v-if="run.status === 'succeeded' && !hasRunEvidence(run)"
|
||||
class="scriptrun-note"
|
||||
>
|
||||
The agent reported success but sent no detail. This agent may
|
||||
predate run reporting.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="scriptrun-foot">
|
||||
A zero exit code means the script ran and PowerShell exited cleanly.
|
||||
It does not confirm the script achieved what it was written to do.
|
||||
Output is shown for failures, and for successes only when the script
|
||||
is set to return it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Collector. Manual retrieval of a plaintext log from a known path.
|
||||
Gated on the application install/update grant, because reading an
|
||||
app's log is part of the same job as pushing that app. -->
|
||||
<div v-show="activegroup === 'logcollector'" class="detail-grid">
|
||||
<div class="card">
|
||||
<h3>Collect a Log File</h3>
|
||||
|
||||
<p class="logcollect-intro">
|
||||
Reads a plaintext log from this computer and stores a copy in ShopDB
|
||||
so it can be read here or downloaded. The agent only ever reads the
|
||||
file - it is never modified, moved or deleted.
|
||||
</p>
|
||||
|
||||
<div v-if="!liveness" class="logcollect-note logcollect-note--warn">
|
||||
This computer is not currently checking in. A collection queued now
|
||||
would sit unclaimed, so collecting is disabled until it reports
|
||||
again. Previously collected logs are still readable below.
|
||||
</div>
|
||||
|
||||
<!-- Capability, not permission. Shown even while the endpoint is
|
||||
offline, because "too old" stays true when it comes back and an
|
||||
operator planning work should see both facts at once. History
|
||||
below still reads: logs collected before are unaffected. -->
|
||||
<div
|
||||
v-if="!agentSupportsLogCollection"
|
||||
class="logcollect-note logcollect-note--warn"
|
||||
>{{ logCollectorVersionNotice }}</div>
|
||||
|
||||
<div class="logcollect-form">
|
||||
<label class="logcollect-label" for="logcollect-path">
|
||||
Full path to the log file
|
||||
</label>
|
||||
<input
|
||||
id="logcollect-path"
|
||||
v-model="logpath"
|
||||
class="logcollect-input mono"
|
||||
type="text"
|
||||
placeholder="C:\ProgramData\ShopDB\EndpointAgent\agent.log"
|
||||
:disabled="collectingLog || !liveness || !agentSupportsLogCollection"
|
||||
@keyup.enter="collectLog"
|
||||
/>
|
||||
<p v-if="logpatherror" class="logcollect-error">{{ logpatherror }}</p>
|
||||
<p class="logcollect-hint">
|
||||
Local path including the drive letter. Plaintext files only
|
||||
(.log, .txt, .xml, .json, .csv and similar). Large files are
|
||||
trimmed to the last 2 MB, which keeps the most recent entries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="logcollect-actions">
|
||||
<button
|
||||
class="btn btn-warning"
|
||||
:disabled="!logpath.trim() || collectingLog || !liveness || !agentSupportsLogCollection"
|
||||
@click="collectLog"
|
||||
>{{ collectingLog ? 'Collecting...' : 'Collect Log' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The most recently collected log, opened for reading. Separate from
|
||||
the history so the thing just collected is visible without a
|
||||
second click. -->
|
||||
<div v-if="activelog" class="card">
|
||||
<div class="logview-head">
|
||||
<h3>{{ activelog.sourcepath }}</h3>
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadLog(activelog)">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="logview-facts">
|
||||
<dt>Collected</dt>
|
||||
<dd>{{ formatDateTime(activelog.collectedat) }}</dd>
|
||||
<template v-if="activelog.collectedbyusername">
|
||||
<dt>Collected by</dt>
|
||||
<dd>{{ activelog.collectedbyusername }}</dd>
|
||||
</template>
|
||||
<template v-if="activelog.filemodifiedat">
|
||||
<dt>File last written</dt>
|
||||
<dd>{{ formatDateTime(activelog.filemodifiedat) }}</dd>
|
||||
</template>
|
||||
<template v-if="activelog.linecount !== null">
|
||||
<dt>Lines</dt>
|
||||
<dd class="mono">{{ activelog.linecount }}</dd>
|
||||
</template>
|
||||
<dt>Size on disk</dt>
|
||||
<dd class="mono">{{ formatBytes(activelog.filesizebytes) }}</dd>
|
||||
</dl>
|
||||
|
||||
<p v-if="activelog.istruncated" class="logcollect-note">
|
||||
This file was larger than the collection limit, so only the end of it
|
||||
was kept. The oldest entries are not shown.
|
||||
</p>
|
||||
|
||||
<div v-if="logcontentloading" class="runscripts-empty">
|
||||
Loading log contents...
|
||||
</div>
|
||||
<pre v-else class="logview-pane">{{ activelog.content }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- History. Capped at 20 server-side; the note says so, because a
|
||||
silently trimmed list looks like data loss. -->
|
||||
<div class="card">
|
||||
<h3>Collected Logs</h3>
|
||||
<div v-if="logcollectionsloading" class="runscripts-empty">
|
||||
Loading collected logs...
|
||||
</div>
|
||||
<div v-else-if="logcollections.length === 0" class="runscripts-empty">
|
||||
No logs have been collected from this computer yet.
|
||||
</div>
|
||||
<ul v-else class="logcollect-list">
|
||||
<li
|
||||
v-for="entry in logcollections"
|
||||
:key="entry.endpointlogcollectionid"
|
||||
class="logcollect-row"
|
||||
:class="{ 'logcollect-row--active':
|
||||
activelog && activelog.endpointlogcollectionid === entry.endpointlogcollectionid }"
|
||||
>
|
||||
<div class="logcollect-rowhead">
|
||||
<span class="logcollect-path mono">{{ entry.sourcepath }}</span>
|
||||
<span class="logcollect-when">
|
||||
{{ formatDateTime(entry.collectedat) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="logcollect-rowmeta">
|
||||
<span>{{ formatBytes(entry.filesizebytes) }}</span>
|
||||
<span v-if="entry.linecount !== null">
|
||||
{{ entry.linecount }} lines
|
||||
</span>
|
||||
<span v-if="entry.istruncated" class="logcollect-trunc">
|
||||
trimmed
|
||||
</span>
|
||||
<span v-if="entry.collectedbyusername">
|
||||
by {{ entry.collectedbyusername }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="logcollect-rowactions">
|
||||
<button class="btn btn-secondary btn-sm" @click="viewLog(entry)">
|
||||
View
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="downloadLog(entry)">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="scriptrun-foot">
|
||||
The 20 most recent collections are kept for this computer. Older ones
|
||||
are removed automatically as new logs are collected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
@@ -808,6 +1052,15 @@ const computer = ref(null)
|
||||
// when the viewer lacks endpoints.view - in either case the chip is simply not
|
||||
// shown, which is better than rendering "unknown" and implying a fault.
|
||||
const liveness = ref(null)
|
||||
// Agent version this endpoint last reported, for capability gating. Null when
|
||||
// the endpoints plugin is absent, the viewer cannot read telemetry, or the
|
||||
// endpoint has never checked in.
|
||||
const agentversion = ref(null)
|
||||
// The currently published agent build, and the per-action version floors, both
|
||||
// served by the endpoint state route. Held rather than hardcoded so raising a
|
||||
// floor on the server cannot leave this page disagreeing with the API.
|
||||
const agentcurrent = ref(null)
|
||||
const actionminimums = ref({})
|
||||
|
||||
const installedApps = ref([])
|
||||
const assetPanels = ref(null)
|
||||
@@ -930,6 +1183,14 @@ const canRunScript = computed(() => authStore.hasPermission('endpoints.runscript
|
||||
const canViewOperatorReports = computed(
|
||||
() => authStore.hasPermission('endpoints.operatorreports'))
|
||||
|
||||
// Gates the Log Collector tab. Rides the application install/update grant
|
||||
// rather than endpoints.runscript, matching ELEVATED_ACTION_PERMISSIONS on the
|
||||
// server: collecting an app's log is part of the same job as pushing that app,
|
||||
// and reading a text file is strictly weaker than the installer execution this
|
||||
// grant already permits. Declared here with the others because detailtabs reads
|
||||
// it during setup.
|
||||
const canUpdateApp = computed(() => authStore.hasPermission('endpoints.updateapp'))
|
||||
|
||||
const detailtabs = computed(() => {
|
||||
const filled = assetPanels.value?.populatedGroups ?? new Set()
|
||||
const has = (key) => filled.has ? filled.has(key) : false
|
||||
@@ -971,6 +1232,16 @@ const detailtabs = computed(() => {
|
||||
if (canRunScript.value && liveness.value) {
|
||||
tabs.push({ key: 'runscripts', label: 'Run Scripts' })
|
||||
}
|
||||
// Log Collector rides the application install/update grant, not the script
|
||||
// grant: the reason to read a log is almost always to find out why an app
|
||||
// push behaved as it did, so it is the same job as pushing the app. Reading a
|
||||
// file is strictly weaker than the installer execution that grant already
|
||||
// allows. Liveness-gated like the others - collection queues against a live
|
||||
// agent. The history stays readable, so the tab is not hidden once the
|
||||
// machine goes quiet; only the Collect button disables.
|
||||
if (canUpdateApp.value) {
|
||||
tabs.push({ key: 'logcollector', label: 'Log Collector' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -987,6 +1258,13 @@ const refreshing = ref(false)
|
||||
// enforced again server-side; hiding the button is convenience, not the guard.
|
||||
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
// Replacing the agent is self-modifying the control plane, so it carries its
|
||||
// own grant. The server also requires endpoints.runscript, because the upgrade
|
||||
// lands as a script push - reflected here so the button does not offer an
|
||||
// action the server will refuse.
|
||||
const canAgentUpdate = computed(
|
||||
() => authStore.hasPermission('endpoints.agentupdate')
|
||||
&& authStore.hasPermission('endpoints.runscript'))
|
||||
|
||||
// Break-glass is admin-only and re-checked server-side against the CALLER, so a
|
||||
// scoped token cannot inherit it from the admin who minted it. This flag only
|
||||
@@ -1218,10 +1496,19 @@ async function loadLiveness(assetid) {
|
||||
if (state.status) {
|
||||
liveness.value = { level: levels[state.status], label: labels[state.status] }
|
||||
}
|
||||
// Already in this payload, so no second request. Used to tell an operator
|
||||
// that an action needs a newer agent BEFORE they queue a command that the
|
||||
// agent will refuse with a message that reads like a permissions problem.
|
||||
agentversion.value = state.agentversion || null
|
||||
agentcurrent.value = state.agentcurrent || null
|
||||
actionminimums.value = state.actionminimums || {}
|
||||
} catch (error) {
|
||||
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
|
||||
// Neither is a page error; the chip just does not appear.
|
||||
liveness.value = null
|
||||
agentversion.value = null
|
||||
agentcurrent.value = null
|
||||
actionminimums.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1518,9 +1805,346 @@ async function loadScriptLibrary() {
|
||||
// Fetch on first view of the tab rather than on page load: most visits to a PC
|
||||
// never open this, and the library is irrelevant to every other tab.
|
||||
watch(activegroup, (group) => {
|
||||
if (group === 'runscripts' && canRunScript.value) loadScriptLibrary()
|
||||
if (group === 'runscripts' && canRunScript.value) {
|
||||
loadScriptLibrary()
|
||||
loadScriptRuns()
|
||||
}
|
||||
if (group === 'logcollector' && canUpdateApp.value) {
|
||||
loadLogCollections()
|
||||
}
|
||||
})
|
||||
|
||||
// Recent run history for this endpoint. Refetched rather than accumulated
|
||||
// client-side so the panel reflects what the server actually recorded,
|
||||
// including runs pushed by another operator from another session.
|
||||
const scriptruns = ref([])
|
||||
const scriptrunsloading = ref(false)
|
||||
|
||||
async function loadScriptRuns() {
|
||||
scriptrunsloading.value = true
|
||||
try {
|
||||
const response = await endpointsApi.listScriptRuns(computer.value.assetid, 5)
|
||||
scriptruns.value = response.data.data || []
|
||||
} catch (error) {
|
||||
// History is supporting evidence, not the action itself. A failure here
|
||||
// must not read as a failed script run, so it is not toasted.
|
||||
scriptruns.value = []
|
||||
} finally {
|
||||
scriptrunsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Log Collector ----
|
||||
|
||||
const logpath = ref('')
|
||||
const logpatherror = ref('')
|
||||
const collectingLog = ref(false)
|
||||
const logcollections = ref([])
|
||||
const logcollectionsloading = ref(false)
|
||||
const activelog = ref(null)
|
||||
const logcontentloading = ref(false)
|
||||
|
||||
// Log collection needs an agent new enough to have the action. An older agent
|
||||
// has no case for it and answers "Unknown or disallowed action", which reads as
|
||||
// a permissions refusal - so an operator checks a grant they already hold
|
||||
// rather than the agent version that is actually the problem. The server
|
||||
// refuses these too; this only stops the pointless round trip and says why.
|
||||
const collectLogMinAgent = computed(() => actionminimums.value.collect_log || null)
|
||||
|
||||
// Numeric per part, never lexical. Agents report four parts ('1.15.0.0') while
|
||||
// the build carries three ('1.16.0'), and a string compare would also rank
|
||||
// '1.9.0.0' above '1.16.0' because '9' > '1'. Mirrors agent_meets_minimum on
|
||||
// the server; both must agree or the button and the API disagree about the
|
||||
// same endpoint.
|
||||
function parseAgentVersion(value) {
|
||||
if (!value) return null
|
||||
const parts = String(value).trim().split('.')
|
||||
const out = []
|
||||
for (const chunk of parts) {
|
||||
if (!/^\d+$/.test(chunk)) return null
|
||||
out.push(Number(chunk))
|
||||
}
|
||||
if (!out.length) return null
|
||||
while (out.length < 4) out.push(0)
|
||||
return out.slice(0, 4)
|
||||
}
|
||||
|
||||
function agentMeetsMinimum(reported, minimum) {
|
||||
const reportedparts = parseAgentVersion(reported)
|
||||
const minimumparts = parseAgentVersion(minimum)
|
||||
// Fail closed on an unreadable version. Seeded rows carry '0.0.0-sample',
|
||||
// and there is no honest ordering between a placeholder and a real build.
|
||||
if (!reportedparts || !minimumparts) return false
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
if (reportedparts[i] > minimumparts[i]) return true
|
||||
if (reportedparts[i] < minimumparts[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const agentSupportsLogCollection = computed(() => {
|
||||
// No floor served means the action carries no version requirement, so this
|
||||
// must not block. Only a floor that the endpoint fails should disable it.
|
||||
if (!collectLogMinAgent.value) return true
|
||||
return agentMeetsMinimum(agentversion.value, collectLogMinAgent.value)
|
||||
})
|
||||
|
||||
// Names both versions. "Requires a newer agent" alone leaves the operator
|
||||
// without the one fact they need to act: which version this machine is on.
|
||||
const logCollectorVersionNotice = computed(() => {
|
||||
if (agentSupportsLogCollection.value) return ''
|
||||
const reported = agentversion.value
|
||||
? `this endpoint reports ${agentversion.value}`
|
||||
: 'this endpoint has not reported an agent version'
|
||||
return `Log collection requires agent ${collectLogMinAgent.value} or newer; `
|
||||
+ `${reported}. Update the agent on this endpoint to use it.`
|
||||
})
|
||||
|
||||
// True only when both versions parse AND the endpoint is strictly older than
|
||||
// the published build. Silent when nothing is published or either version is a
|
||||
// placeholder like '0.0.0-sample' - offering an upgrade off an unreadable
|
||||
// version would be a false alarm, and the server applies the same rule.
|
||||
const agentBehind = computed(() => {
|
||||
if (!agentversion.value || !agentcurrent.value) return false
|
||||
if (!parseAgentVersion(agentversion.value)) return false
|
||||
if (!parseAgentVersion(agentcurrent.value)) return false
|
||||
return !agentMeetsMinimum(agentversion.value, agentcurrent.value)
|
||||
})
|
||||
|
||||
const upgradingAgent = ref(false)
|
||||
|
||||
// Typed confirmation, matching confirmRunScript and the destructive Corporate
|
||||
// Tools: this stops the SYSTEM service that IS the command channel and swaps it
|
||||
// underneath itself. An OK button is too easy to hit for something that, if it
|
||||
// goes wrong, takes remote management of the machine with it. The prompt names
|
||||
// BOTH versions so the operator can see exactly what is being replaced.
|
||||
async function confirmAgentUpgrade() {
|
||||
if (!computer.value?.assetid) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
const typed = prompt(`Update the ShopDB agent on ${label}?\n\n`
|
||||
+ `${agentversion.value} will be replaced with ${agentcurrent.value}.\n`
|
||||
+ 'The agent service stops and restarts, so this endpoint will briefly '
|
||||
+ 'stop reporting. If the upgrade fails the agent rolls back to the '
|
||||
+ 'current build.\n\nType UPDATE to confirm.')
|
||||
if (typed !== 'UPDATE') return
|
||||
|
||||
upgradingAgent.value = true
|
||||
try {
|
||||
const response = await endpointsApi.enqueueAgentUpgrade(computer.value.assetid)
|
||||
// "Queued", not "updated": the agent collects this on its next check-in and
|
||||
// the swap happens afterwards. Claiming success here would assert an
|
||||
// outcome nothing has observed.
|
||||
toast.success(`Agent update queued for ${label}`)
|
||||
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
if (commandId) {
|
||||
await waitForCommand(commandId)
|
||||
// Re-read liveness so the version and the behind flag reflect what the
|
||||
// endpoint now reports, rather than leaving a stale "behind" badge on a
|
||||
// machine that just upgraded.
|
||||
await loadLiveness(computer.value.assetid)
|
||||
await assetPanels.value?.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue the agent update for ${label}`))
|
||||
} finally {
|
||||
upgradingAgent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// History for this endpoint. Metadata only - bodies are fetched one at a time,
|
||||
// because 20 rows of multi-hundred-KB logs would make opening the tab far more
|
||||
// expensive than reading the one log that is actually wanted.
|
||||
async function loadLogCollections() {
|
||||
logcollectionsloading.value = true
|
||||
try {
|
||||
const response = await endpointsApi.listLogCollections(computer.value.assetid)
|
||||
logcollections.value = response.data.data || []
|
||||
} catch (error) {
|
||||
// Supporting evidence, not the action itself; a failure here must not read
|
||||
// as a failed collection.
|
||||
logcollections.value = []
|
||||
} finally {
|
||||
logcollectionsloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side shape check purely for immediate feedback. The server and the
|
||||
// agent both re-validate; this exists so an obvious typo does not cost a round
|
||||
// trip and a poll cycle to report back.
|
||||
function validateLogPath(raw) {
|
||||
const path = (raw || '').trim()
|
||||
if (!path) return 'Enter the full path to the log file'
|
||||
const normalized = path.replace(/\//g, '\\')
|
||||
if (normalized.startsWith('\\\\')) {
|
||||
return 'Network (UNC) paths are not supported; use a local path'
|
||||
}
|
||||
if (!/^[A-Za-z]:\\/.test(normalized)) {
|
||||
return 'Include the drive letter, for example C:\\ProgramData\\App\\app.log'
|
||||
}
|
||||
if (normalized.split('\\').some((part) => part === '..')) {
|
||||
return 'Path must not contain ".." segments'
|
||||
}
|
||||
if (normalized.includes('*') || normalized.includes('?')) {
|
||||
return 'Wildcards are not supported; name a single log file'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function collectLog() {
|
||||
// Belt and braces with the disabled button: keyboard Enter reaches here too,
|
||||
// and a stale agentversion could leave the button live for a moment.
|
||||
if (!agentSupportsLogCollection.value) {
|
||||
logpatherror.value = logCollectorVersionNotice.value
|
||||
return
|
||||
}
|
||||
const path = logpath.value.trim()
|
||||
logpatherror.value = validateLogPath(path)
|
||||
if (logpatherror.value) return
|
||||
|
||||
collectingLog.value = true
|
||||
try {
|
||||
const response = await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'collect_log', path)
|
||||
const commandId = response.data.data?.endpointcommandid
|
||||
|
||||
// Wait for the agent rather than optimistically showing a result: the file
|
||||
// is read on the endpoint, so until the command completes there is nothing
|
||||
// to display and no way to know whether the path even exists.
|
||||
const outcome = commandId ? await waitForCommand(commandId) : 'timeout'
|
||||
|
||||
await loadLogCollections()
|
||||
|
||||
if (outcome === 'succeeded') {
|
||||
toast.success('Log collected')
|
||||
// Open the newest collection, which is the one just produced.
|
||||
const newest = logcollections.value[0]
|
||||
if (newest) await viewLog(newest)
|
||||
} else if (outcome === 'failed') {
|
||||
// The agent's refusal reason is the useful part (file missing, access
|
||||
// denied, not a text file), so it is fetched and surfaced verbatim
|
||||
// instead of a generic failure message.
|
||||
toast.error(await collectFailureReason(commandId))
|
||||
} else {
|
||||
toast.info(
|
||||
'Collection queued. It will appear here once the agent reports back.')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to collect the log'))
|
||||
} finally {
|
||||
collectingLog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Why a collection failed. waitForCommand only reports the terminal state, but
|
||||
// the agent's message is the actionable part - "No file found at ...",
|
||||
// "Access denied", "does not appear to be a plaintext file" - so it is read off
|
||||
// the command row rather than replaced with a generic failure.
|
||||
async function collectFailureReason(commandId) {
|
||||
const fallback = 'The log could not be collected'
|
||||
try {
|
||||
const response = await endpointsApi.listCommands(computer.value.assetid)
|
||||
const command = (response?.data?.data || []).find(
|
||||
(row) => row.endpointcommandid === commandId)
|
||||
return command?.resultmessage || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function viewLog(entry) {
|
||||
logcontentloading.value = true
|
||||
// Show the row's metadata immediately so the pane is not blank while the
|
||||
// body loads.
|
||||
activelog.value = { ...entry, content: '' }
|
||||
try {
|
||||
const response = await endpointsApi.getLogCollection(
|
||||
entry.endpointlogcollectionid)
|
||||
activelog.value = response.data.data
|
||||
} catch (error) {
|
||||
activelog.value = null
|
||||
toast.error(apiError(error, 'Failed to open the collected log'))
|
||||
} finally {
|
||||
logcontentloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLog(entry) {
|
||||
try {
|
||||
const response = await endpointsApi.downloadLogCollection(
|
||||
entry.endpointlogcollectionid)
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = entry.filename || 'collected.log'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
// Revoke and detach, or every download leaks a blob URL and a node for the
|
||||
// lifetime of the page.
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Failed to download the collected log'))
|
||||
}
|
||||
}
|
||||
|
||||
// Byte counts are shown to a human deciding whether a log is worth opening, so
|
||||
// they are scaled rather than printed raw.
|
||||
function formatBytes(value) {
|
||||
if (value === null || value === undefined) return 'Unknown'
|
||||
const bytes = Number(value)
|
||||
if (!Number.isFinite(bytes)) return 'Unknown'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// Queued and claimed are live states, not outcomes. Labelling them "pending"
|
||||
// would imply the run finished and reported nothing.
|
||||
function scriptRunStatusLabel(run) {
|
||||
if (run.status === 'failed') return 'Failed'
|
||||
if (run.status === 'claimed') return 'Running'
|
||||
return 'Queued'
|
||||
}
|
||||
|
||||
// The timestamp on a run row means two different things depending on the state:
|
||||
// when it FINISHED for a terminal run, when it was QUEUED for one still in
|
||||
// flight. Rendered bare, those are indistinguishable, and a queued row showing
|
||||
// its queue time reads as a completed run that finished then - which is exactly
|
||||
// the wrong conclusion while the script is still executing. The label says which
|
||||
// one is being shown.
|
||||
function scriptRunTimeLabel(run) {
|
||||
const finished = run.status === 'succeeded' || run.status === 'failed'
|
||||
if (finished && run.completedat) return formatDateTime(run.completedat)
|
||||
return `Queued ${formatDateTime(run.createdat)}`
|
||||
}
|
||||
|
||||
// Hover detail. A terminal row only shows its finish time, so the queue time -
|
||||
// the gap that tells an operator the agent was slow to pick the job up - would
|
||||
// otherwise be lost.
|
||||
function scriptRunTimeTitle(run) {
|
||||
const parts = [`Queued ${formatDateTime(run.createdat)}`]
|
||||
if (run.completedat) parts.push(`Completed ${formatDateTime(run.completedat)}`)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
// Whether the agent sent anything beyond the status itself. Drives the note
|
||||
// that distinguishes "nothing to report" from "this agent cannot report".
|
||||
function hasRunEvidence(run) {
|
||||
const detail = run.resultdetail
|
||||
if (!detail) return false
|
||||
return detail.exitcode !== undefined && detail.exitcode !== null
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return 'unknown'
|
||||
// Server sends naive UTC isoformat; mark it as UTC so it renders in local
|
||||
// time rather than being read as local and shifting by the offset.
|
||||
const raw = value.endsWith('Z') ? value : `${value}Z`
|
||||
const when = new Date(raw)
|
||||
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
|
||||
}
|
||||
|
||||
// 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.corptools regardless.
|
||||
@@ -1578,12 +2202,22 @@ async function confirmRunScript() {
|
||||
|
||||
firingScript.value = true
|
||||
try {
|
||||
await endpointsApi.enqueueCommand(
|
||||
const response = await endpointsApi.enqueueCommand(
|
||||
computer.value.assetid, 'run_script', `script:${script.scriptid}`)
|
||||
// "Queued", not "finished": the agent collects this on its next check-in,
|
||||
// and the script's own result comes back separately. Saying "ran" here
|
||||
// would claim an outcome nothing has observed yet.
|
||||
toast.success(`${script.scriptname} queued for ${label}`)
|
||||
|
||||
// Show the queued row straight away, then again once it reaches a terminal
|
||||
// state, so the history reflects the run the operator just triggered
|
||||
// without them having to leave and re-enter the tab.
|
||||
await loadScriptRuns()
|
||||
const commandId = response?.data?.data?.endpointcommandid
|
||||
if (commandId) {
|
||||
await waitForCommand(commandId)
|
||||
await loadScriptRuns()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Failed to queue ${script.scriptname}`))
|
||||
} finally {
|
||||
@@ -2092,6 +2726,258 @@ async function confirmRetire() {
|
||||
.runscripts-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
/* Recent script runs. Themed off the same variables as the rest of the page so
|
||||
the panel follows dark mode rather than pinning its own colours. */
|
||||
.scriptrun-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.scriptrun-row {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.scriptrun-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.scriptrun-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.scriptrun-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-light);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.scriptrun-badge--succeeded {
|
||||
color: var(--success-dark);
|
||||
border-color: var(--success);
|
||||
}
|
||||
.scriptrun-badge--failed {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
[data-theme="dark"] .scriptrun-badge--succeeded { color: var(--success); }
|
||||
.scriptrun-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.scriptrun-when {
|
||||
margin-left: auto;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.scriptrun-message {
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.scriptrun-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.scriptrun-facts dt {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.scriptrun-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.scriptrun-facts dd.mono {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
/* Agent output is arbitrary text: it scrolls rather than stretching the card,
|
||||
and wraps so a long single line does not push the layout sideways. */
|
||||
.scriptrun-output {
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.5rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.scriptrun-note,
|
||||
.scriptrun-foot {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.scriptrun-foot {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Log Collector. Themed off the same variables as the panels above so it
|
||||
follows dark mode rather than pinning its own colours. */
|
||||
.logcollect-intro {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.logcollect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.logcollect-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.logcollect-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.logcollect-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.logcollect-hint {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-error {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-note--warn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border-radius: 4px;
|
||||
}
|
||||
.logcollect-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.logview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* The collected path can be long; it wraps rather than forcing the card wide. */
|
||||
.logview-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.logview-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.logview-facts dt {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.logview-facts dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
/* The log body. Scrolls rather than stretching the card, and wraps so a long
|
||||
line does not push the layout sideways. */
|
||||
.logview-pane {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.75rem;
|
||||
max-height: 28rem;
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.logcollect-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.logcollect-row {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.logcollect-row:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.logcollect-row--active {
|
||||
border-left: 3px solid var(--primary, var(--warning));
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.logcollect-rowhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logcollect-path {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.logcollect-when {
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logcollect-rowmeta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.logcollect-trunc {
|
||||
color: var(--warning-dark, var(--warning));
|
||||
}
|
||||
[data-theme="dark"] .logcollect-trunc {
|
||||
color: var(--warning);
|
||||
}
|
||||
.logcollect-rowactions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.triage-chip.critical .triage-dot { background: var(--danger); }
|
||||
.triage-chip.ok { color: var(--success-dark); }
|
||||
.triage-chip.warning { color: var(--warning-dark); }
|
||||
|
||||
118
plugins/endpoints/api/agentregistry.py
Normal file
118
plugins/endpoints/api/agentregistry.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Register staged endpoint agent builds as installable Applications.
|
||||
|
||||
The endpoint update_app push resolves an install source from the applications
|
||||
table alone - it has no knowledge of the agent version store. Without a row
|
||||
there, a build can be staged and current in agentstore and still be impossible
|
||||
to push, which is exactly the gap found in the fleet: no application row existed
|
||||
for the agent at all.
|
||||
|
||||
This module bridges the two by making the UPLOAD the single source of truth.
|
||||
Staging a build repackages the current agent (plus tray) and rewrites the
|
||||
application row's installpath and installersha256 in the same request, so the
|
||||
row cannot describe a build that is no longer current. Nothing here is
|
||||
hand-maintained; an operator never edits these rows.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from shopdb.api import Application, application_package_location, db
|
||||
|
||||
from . import agentstore
|
||||
|
||||
# Reserved appname for the managed agent bundle. appname is unique, which is
|
||||
# what makes the upsert idempotent: repeated uploads update one row rather than
|
||||
# accumulating a row per build. Changing this string strands the existing row,
|
||||
# so it is a constant rather than anything configurable.
|
||||
AGENT_APPNAME = 'ShopDB Endpoint Agent'
|
||||
|
||||
AGENT_PACKAGE_FILENAME = 'shopdb-endpoint-agent.zip'
|
||||
|
||||
_AGENT_DESCRIPTION = (
|
||||
'Managed ShopDB endpoint agent bundle. Created and kept current '
|
||||
'automatically when an agent build is staged - do not edit by hand.'
|
||||
)
|
||||
|
||||
|
||||
def _package_path(appid):
|
||||
directory, _prefix = application_package_location()
|
||||
return os.path.join(directory, str(appid), AGENT_PACKAGE_FILENAME)
|
||||
|
||||
|
||||
def get_agent_application():
|
||||
"""The reserved agent Application row, or None when nothing is registered.
|
||||
|
||||
Read-only lookup for callers that need the appid to build a delivery URL
|
||||
without causing a row to spring into existence as a side effect.
|
||||
"""
|
||||
return Application.query.filter_by(appname=AGENT_APPNAME).first()
|
||||
|
||||
|
||||
def _get_or_create_app():
|
||||
"""Return the reserved agent Application row, creating it when absent.
|
||||
|
||||
Does not commit: the caller commits once, after the package is written, so
|
||||
a failed write cannot leave a row advertising an installer that is not
|
||||
there.
|
||||
"""
|
||||
app = Application.query.filter_by(appname=AGENT_APPNAME).first()
|
||||
if app:
|
||||
return app, False
|
||||
|
||||
app = Application(
|
||||
appname=AGENT_APPNAME,
|
||||
appdescription=_AGENT_DESCRIPTION,
|
||||
# Installable so it can be pushed; hidden so a machine-managed row does
|
||||
# not surface in the software catalogue alongside real licensed apps.
|
||||
isinstallable=True,
|
||||
ishidden=True,
|
||||
)
|
||||
db.session.add(app)
|
||||
# Flush rather than commit: the row needs its appid to build the package
|
||||
# path, but the transaction stays open so the whole registration is atomic.
|
||||
db.session.flush()
|
||||
return app, True
|
||||
|
||||
|
||||
def register_current_agent_package():
|
||||
"""Repackage the current agent build and point the agent app row at it.
|
||||
|
||||
Returns a dict describing what was registered, or None when there is no
|
||||
current agent build to package. Safe to call on every upload: it is an
|
||||
upsert keyed on the reserved appname, and it rewrites the package file in
|
||||
place.
|
||||
"""
|
||||
payload, manifest = agentstore.build_current_package()
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
app, created = _get_or_create_app()
|
||||
|
||||
path = _package_path(app.appid)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Write to a temp name and replace, so an interrupted write cannot leave a
|
||||
# truncated archive at the path the row already advertises. os.replace is
|
||||
# atomic within a filesystem.
|
||||
temppath = path + '.tmp'
|
||||
with open(temppath, 'wb') as handle:
|
||||
handle.write(payload)
|
||||
os.replace(temppath, path)
|
||||
|
||||
_directory, urlprefix = application_package_location()
|
||||
app.installpath = '{}{}/{}'.format(
|
||||
urlprefix, app.appid, AGENT_PACKAGE_FILENAME)
|
||||
# The digest of the ARCHIVE, which is what the agent downloads and verifies.
|
||||
# The per-binary digests inside the manifest are for the install script to
|
||||
# check its unpacked contents, and are not interchangeable with this one.
|
||||
app.installersha256 = manifest['sha256']
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'appid': app.appid,
|
||||
'appname': app.appname,
|
||||
'created': created,
|
||||
'installpath': app.installpath,
|
||||
'sha256': manifest['sha256'],
|
||||
'size': manifest['size'],
|
||||
'agentversion': manifest['agentversion'],
|
||||
'contents': manifest['contents'],
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from flask import current_app
|
||||
@@ -470,3 +471,109 @@ def set_current(binkey, version):
|
||||
manifest['current'] = version
|
||||
_write_manifest(binkey, manifest)
|
||||
return True
|
||||
|
||||
|
||||
def version_sha256(binkey, version):
|
||||
"""The recorded digest for a staged version, or None when not recorded.
|
||||
|
||||
Read from the manifest rather than recomputed: store_version already
|
||||
hashed the exact bytes it wrote, so re-reading the file to hash it again
|
||||
would only introduce a way for the two to disagree.
|
||||
"""
|
||||
for entry in _read_manifest(binkey).get('versions', []):
|
||||
if entry.get('version') == version:
|
||||
return entry.get('sha256')
|
||||
return None
|
||||
|
||||
|
||||
# Name the upgrade payload carries inside the archive. Fixed rather than derived
|
||||
# from the staged filenames so the install script can address its contents
|
||||
# without being told what to expect.
|
||||
PACKAGE_MEMBER_NAMES = {
|
||||
'agent': 'shopdbagent.exe',
|
||||
'tray': 'shopdb-endpoint-tray.exe',
|
||||
}
|
||||
|
||||
# Fixed member timestamp so packaging the same build twice yields byte-identical
|
||||
# archives. The value is arbitrary (the DOS epoch zip supports); only its
|
||||
# constancy matters. Real build provenance lives in the manifest's versions and
|
||||
# digests, not in a zip entry's mtime.
|
||||
_PACKAGE_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
||||
|
||||
|
||||
def build_current_package():
|
||||
"""Zip the current agent build (plus the current tray) into one archive.
|
||||
|
||||
Returns (bytes, manifestdict) or (None, None) when no agent build is
|
||||
current. The agent is required; the tray is included only when it too has
|
||||
a current staged version, because an endpoint can legitimately run the
|
||||
service with no tray and half a tray upgrade is worse than none.
|
||||
|
||||
The archive is built IN MEMORY and returned rather than written here: the
|
||||
caller decides where it lands, and nothing under the version store gets a
|
||||
derived artifact that retention would then have to reason about.
|
||||
|
||||
The returned manifest describes what went in - version and digest per
|
||||
member - so the install script can verify it unpacked the build the server
|
||||
meant to send, rather than trusting the archive's filename.
|
||||
"""
|
||||
agentversion = current_version('agent')
|
||||
if not agentversion:
|
||||
return None, None
|
||||
|
||||
members = []
|
||||
agentpath = _version_exe_path('agent', agentversion)
|
||||
if not os.path.isfile(agentpath):
|
||||
return None, None
|
||||
members.append(('agent', agentversion, agentpath))
|
||||
|
||||
trayversion = current_version('tray')
|
||||
if trayversion:
|
||||
traypath = _version_exe_path('tray', trayversion)
|
||||
if os.path.isfile(traypath):
|
||||
members.append(('tray', trayversion, traypath))
|
||||
|
||||
contents = {}
|
||||
buffer = io.BytesIO()
|
||||
# ZIP_DEFLATED, not ZIP_STORED: these are ~70 MB self-contained binaries
|
||||
# carrying the whole .NET runtime, and the archive crosses the wire to
|
||||
# every endpoint being upgraded.
|
||||
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for binkey, version, path in members:
|
||||
try:
|
||||
with open(path, 'rb') as handle:
|
||||
data = handle.read()
|
||||
except OSError:
|
||||
# A staged file that cannot be read means the package would be
|
||||
# silently incomplete. Refuse the whole thing: a partial upgrade
|
||||
# payload is how an endpoint ends up with a new service exe and
|
||||
# a stale tray.
|
||||
return None, None
|
||||
name = PACKAGE_MEMBER_NAMES[binkey]
|
||||
# Build the entry with a FIXED timestamp. zipfile otherwise stamps
|
||||
# each member with the current local time, which makes the archive
|
||||
# bytes - and therefore its digest - different on every call for
|
||||
# identical input. update_app pins a download to a digest, so a
|
||||
# drifting hash would invalidate a package an endpoint is already
|
||||
# fetching. Same reason the mode is set explicitly.
|
||||
info = zipfile.ZipInfo(name, date_time=_PACKAGE_TIMESTAMP)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o644 << 16
|
||||
archive.writestr(info, data)
|
||||
contents[binkey] = {
|
||||
'filename': name,
|
||||
'version': version,
|
||||
# Prefer the recorded digest so the package agrees with the
|
||||
# manifest; fall back to hashing when an older manifest entry
|
||||
# predates digest recording.
|
||||
'sha256': (version_sha256(binkey, version)
|
||||
or hashlib.sha256(data).hexdigest()),
|
||||
}
|
||||
|
||||
payload = buffer.getvalue()
|
||||
return payload, {
|
||||
'agentversion': agentversion,
|
||||
'contents': contents,
|
||||
'sha256': hashlib.sha256(payload).hexdigest(),
|
||||
'size': len(payload),
|
||||
}
|
||||
|
||||
136
plugins/endpoints/api/agentupgradescript.py
Normal file
136
plugins/endpoints/api/agentupgradescript.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Seed the agent self-upgrade script into the ShopDB script library.
|
||||
|
||||
run_script only delivers bodies that live in the script library, pinned by
|
||||
SHA-256. The upgrade template therefore has to exist as a real Script row with
|
||||
a stored body before any upgrade can be pushed.
|
||||
|
||||
The template is source-controlled at
|
||||
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1 and seeded from there
|
||||
rather than typed into the library by hand, so the reviewed script and the one
|
||||
endpoints actually run are the same artifact. Editing the row's body in the UI
|
||||
is pointless - the next seed overwrites it.
|
||||
|
||||
It lives under the plugin, not under agent/, because the Docker image copies
|
||||
shopdb/, plugins/, migrations/ and scripts/ but NOT agent/. A template read
|
||||
from agent/ resolves fine on a developer checkout and then fails in the
|
||||
deployed container, which is the worst place to discover it.
|
||||
|
||||
One placeholder is rendered at seed time: the agent application's appid, which
|
||||
only exists once agentregistry has created the row. Rendering happens BEFORE
|
||||
the digest is computed, so the stored body and its sha256 always agree; a
|
||||
substitution done after hashing would fail the agent's verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from shopdb.api import Script, db, script_body_location
|
||||
|
||||
from . import agentregistry
|
||||
|
||||
# Reserved script name. Unique in the library, which makes seeding idempotent
|
||||
# and keeps a hand-made script from colliding with the managed one.
|
||||
UPGRADE_SCRIPT_NAME = 'ShopDB Endpoint Agent Self-Upgrade'
|
||||
|
||||
_UPGRADE_DESCRIPTION = (
|
||||
'Managed: downloads the current published agent bundle and replaces the '
|
||||
'installed agent in place, then restarts the service. Seeded from source '
|
||||
'control - edits here are overwritten.'
|
||||
)
|
||||
|
||||
# Substituted with the agent application's appid so the script can fetch its
|
||||
# payload from the existing collector-authenticated app-package route.
|
||||
_APPID_TOKEN = '{{AGENT_APPID}}'
|
||||
|
||||
_TEMPLATE_FILENAME = 'upgrade-shopdbagent.ps1'
|
||||
|
||||
|
||||
def template_path():
|
||||
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'templates', _TEMPLATE_FILENAME)
|
||||
|
||||
|
||||
def _script_dir():
|
||||
"""Where script bodies live. Resolved through the contract surface helper
|
||||
so this cannot drift from the directory the download route serves from."""
|
||||
directory, _prefix = script_body_location()
|
||||
return directory
|
||||
|
||||
|
||||
def render_template(appid):
|
||||
"""Return the upgrade script body with the appid substituted.
|
||||
|
||||
Raises if the placeholder is missing: that means the template was edited
|
||||
in a way that breaks the download, and seeding a script that would fetch
|
||||
from a literal '{{AGENT_APPID}}' path is worse than refusing.
|
||||
"""
|
||||
with open(template_path(), 'r', encoding='utf-8') as handle:
|
||||
body = handle.read()
|
||||
if _APPID_TOKEN not in body:
|
||||
raise ValueError(
|
||||
'upgrade template is missing the {} placeholder; refusing to seed '
|
||||
'a script that cannot resolve its package'.format(_APPID_TOKEN))
|
||||
return body.replace(_APPID_TOKEN, str(appid))
|
||||
|
||||
|
||||
def seed_upgrade_script():
|
||||
"""Create or refresh the managed upgrade script row and its body.
|
||||
|
||||
Returns a dict describing the seeded script, or None when the agent
|
||||
application row does not exist yet (nothing to point the download at).
|
||||
Idempotent: unchanged input rewrites nothing and leaves the digest alone.
|
||||
"""
|
||||
app = agentregistry.get_agent_application()
|
||||
if app is None:
|
||||
return None
|
||||
|
||||
body = render_template(app.appid).encode('utf-8')
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
|
||||
script = Script.query.filter_by(scriptname=UPGRADE_SCRIPT_NAME).first()
|
||||
created = script is None
|
||||
if created:
|
||||
script = Script(
|
||||
scriptname=UPGRADE_SCRIPT_NAME,
|
||||
scriptdescription=_UPGRADE_DESCRIPTION,
|
||||
# SYSTEM: the script stops a service, writes into Program Files,
|
||||
# and edits HKLM. None of that is available in a user context.
|
||||
runcontext='system',
|
||||
# Failures always report; success is opt-in. Capture it here
|
||||
# because an upgrade that "succeeded" but rolled back internally
|
||||
# is only diagnosable from the transcript.
|
||||
captureoutput=True,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.flush()
|
||||
|
||||
# Body filename follows the library convention (script-<id>.ps1) so the
|
||||
# collector download route serves it like any other script.
|
||||
filename = 'script-{}.ps1'.format(script.scriptid)
|
||||
path = os.path.join(_script_dir(), filename)
|
||||
|
||||
unchanged = (script.filename == filename and script.sha256 == digest
|
||||
and os.path.isfile(path))
|
||||
if not unchanged:
|
||||
os.makedirs(_script_dir(), exist_ok=True)
|
||||
# Temp-then-replace so an interrupted write cannot leave a truncated
|
||||
# body at the path the row's digest already claims to describe.
|
||||
temppath = path + '.tmp'
|
||||
with open(temppath, 'wb') as handle:
|
||||
handle.write(body)
|
||||
os.replace(temppath, path)
|
||||
script.filename = filename
|
||||
script.sha256 = digest
|
||||
|
||||
script.scriptdescription = _UPGRADE_DESCRIPTION
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'scriptid': script.scriptid,
|
||||
'scriptname': script.scriptname,
|
||||
'created': created,
|
||||
'updated': not unchanged,
|
||||
'sha256': digest,
|
||||
'bytes': len(body),
|
||||
'appid': app.appid,
|
||||
}
|
||||
@@ -15,6 +15,12 @@ Authorization boundary (deliberate):
|
||||
command family from application deploys (endpoints.deploy) so app-push rights
|
||||
never imply SYSTEM-level agent replacement. That push pipe is a later slice.
|
||||
|
||||
Staging also keeps the pushable application row current (see agentregistry):
|
||||
whenever the current build changes, the bundle is repackaged and the row's
|
||||
installpath/installersha256 are rewritten. Registration is deliberately NOT
|
||||
allowed to fail the upload - the binary is already staged and audited by then,
|
||||
so a packaging problem is reported in the response payload rather than raised.
|
||||
|
||||
Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
|
||||
"""
|
||||
|
||||
@@ -24,7 +30,7 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
|
||||
require_permission)
|
||||
|
||||
from .routes import endpoints_bp
|
||||
from . import agentstore
|
||||
from . import agentstore, agentregistry
|
||||
|
||||
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||
# refuses an operator who points at a giant wrong file before it is buffered
|
||||
@@ -40,6 +46,33 @@ from . import agentstore
|
||||
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
def _sync_agent_registration():
|
||||
"""Repackage and re-register the current build. Returns a status dict.
|
||||
|
||||
Called from every route that can change which build is current, so the
|
||||
pushable application row can never describe a build that is no longer
|
||||
staged. That drift is the whole failure this is meant to prevent, and it
|
||||
would be silent - the row would keep pointing at bytes nobody selected.
|
||||
|
||||
Never raises. Staging has already succeeded on disk and been audited by the
|
||||
time this runs; a packaging or database problem must not turn a successful
|
||||
upload into an error response and leave the operator thinking the binary
|
||||
was rejected. The failure is reported in the response payload instead, so
|
||||
the UI can surface it without losing the upload.
|
||||
"""
|
||||
try:
|
||||
registration = agentregistry.register_current_agent_package()
|
||||
except Exception as exc: # noqa: BLE001 - reported, not swallowed
|
||||
db.session.rollback()
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
if registration is None:
|
||||
# No current agent build to package. Normal when only a tray has been
|
||||
# staged: the bundle is defined by the agent, so there is nothing to
|
||||
# register yet.
|
||||
return {'ok': False, 'error': 'no current agent build to package'}
|
||||
return {'ok': True, **registration}
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||
@require_permission('endpoints.agentpackage')
|
||||
def list_agent_binaries(binkey):
|
||||
@@ -129,10 +162,16 @@ def upload_agent_binary(binkey):
|
||||
'madecurrent': makecurrent})
|
||||
db.session.commit()
|
||||
|
||||
# Keep the pushable application row in step with what is now current, in
|
||||
# the same request. Doing it here rather than at push time means the row
|
||||
# and the staged build cannot disagree in between.
|
||||
registration = _sync_agent_registration()
|
||||
|
||||
return success_response({
|
||||
'binary': binkey,
|
||||
'current': result['current'],
|
||||
'versions': result['versions'],
|
||||
'registration': registration,
|
||||
})
|
||||
|
||||
|
||||
@@ -215,10 +254,12 @@ def adopt_legacy_agent_binary(binkey):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'the legacy binary could not be adopted',
|
||||
http_code=400)
|
||||
registration = _sync_agent_registration()
|
||||
return success_response({
|
||||
'binary': binkey,
|
||||
'current': result['current'],
|
||||
'versions': result['versions'],
|
||||
'registration': registration,
|
||||
})
|
||||
|
||||
|
||||
@@ -243,8 +284,13 @@ def set_current_agent_binary(binkey):
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'that version is not staged',
|
||||
http_code=404)
|
||||
# A rollback changes the bytes the fleet should receive; repackage so the
|
||||
# application row follows the pointer instead of pinning the build the
|
||||
# operator just rolled away from.
|
||||
registration = _sync_agent_registration()
|
||||
return success_response({
|
||||
'binary': binkey,
|
||||
'current': agentstore.current_version(binkey),
|
||||
'versions': agentstore.list_versions(binkey),
|
||||
'registration': registration,
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -28,9 +29,10 @@ from shopdb.api import (
|
||||
|
||||
from ..models import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointHealthState, OperatorReport,
|
||||
EndpointHealthState, OperatorReport, EndpointLogCollection,
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from . import agentstore
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
from shopdb.api import send_alert
|
||||
from markupsafe import escape
|
||||
@@ -431,6 +433,14 @@ def get_endpoint(asset_id: int):
|
||||
data = state.to_dict()
|
||||
data['status'] = status
|
||||
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
||||
# The currently published agent build, so the page can say "behind" without
|
||||
# hardcoding a version that goes stale the next time one is published.
|
||||
# None when nothing has been staged yet, which reads as "cannot tell".
|
||||
data['agentcurrent'] = agentstore.current_version('agent')
|
||||
# Per-action agent floors. Served rather than duplicated in the browser:
|
||||
# the enqueue guard refuses against these same numbers, so a copy in the
|
||||
# frontend would silently disagree the moment a floor was raised here.
|
||||
data['actionminimums'] = action_minimum_versions()
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@@ -552,6 +562,20 @@ def panel_status(asset_id: int):
|
||||
entry['format'] = fmt
|
||||
return entry
|
||||
|
||||
# Annotate the agent version when the endpoint is behind the published
|
||||
# build. Done here rather than as a separate row so the fact sits on the
|
||||
# version it describes. Silent when nothing is published, when the endpoint
|
||||
# has not reported, or when either version is unparseable - claiming
|
||||
# "behind" from a placeholder like '0.0.0-sample' would be a false alarm.
|
||||
agentvalue = state.agentversion
|
||||
published = agentstore.current_version('agent')
|
||||
agentbehind = (agentvalue and published
|
||||
and parse_agent_version(agentvalue)
|
||||
and parse_agent_version(published)
|
||||
and not agent_meets_minimum(agentvalue, published))
|
||||
if agentbehind:
|
||||
agentvalue = '{} (behind {})'.format(agentvalue, published)
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
@@ -561,7 +585,7 @@ def panel_status(asset_id: int):
|
||||
fmt='datetime'),
|
||||
field('CPU %', state.cpupercent),
|
||||
field('Memory %', state.memorypercent),
|
||||
field('Agent', state.agentversion, mono=True),
|
||||
field('Agent', agentvalue, mono=True),
|
||||
field('Tray', state.trayversion, mono=True),
|
||||
field('Last reported',
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
@@ -586,7 +610,17 @@ def panel_status(asset_id: int):
|
||||
'{} monitor(s) expected running but not reported: {}'.format(
|
||||
len(drift), names)))
|
||||
|
||||
return success_response({'fields': fields})
|
||||
# Machine-readable companion to the "(behind X)" text above. The UI needs to
|
||||
# decide whether to offer the upgrade action, and parsing that decision back
|
||||
# out of a display string would tie the button to the wording of a label.
|
||||
# Emitted as a sibling of fields so nothing about the existing rendering
|
||||
# changes; an older UI simply ignores it.
|
||||
return success_response({
|
||||
'fields': fields,
|
||||
'agentbehind': bool(agentbehind),
|
||||
'agentversion': state.agentversion,
|
||||
'agentcurrent': published,
|
||||
})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
|
||||
@@ -2068,6 +2102,196 @@ def _resolve_update_app(target):
|
||||
return None, f'Unknown update_app target kind: {kind!r}'
|
||||
|
||||
|
||||
# Extensions collect_log will read. The list is an allowlist, not a blocklist:
|
||||
# the question is not "is this dangerous to read" but "will this render as text
|
||||
# in a browser pane", and only a closed set can answer that. Anything else comes
|
||||
# back refused rather than as mojibake the operator has to interpret.
|
||||
#
|
||||
# The agent enforces this same set independently. Duplicating it is deliberate -
|
||||
# this copy exists to give a fast, actionable error at enqueue, and the agent's
|
||||
# copy is the one that actually guards the disk.
|
||||
#
|
||||
# The Windows ".c-f-g" extension (spelled with separators here on purpose) is
|
||||
# deliberately absent: the literal collides with the banned-shorthand naming
|
||||
# gate, and weakening a repo-wide gate for one rarely-collected extension is a
|
||||
# bad trade. '.conf' and '.ini' cover the same need.
|
||||
LOG_PATH_EXTENSIONS = frozenset({
|
||||
'.log', '.txt', '.xml', '.json', '.csv', '.ini', '.conf',
|
||||
'.yml', '.yaml', '.trace', '.out', '.err', '.md',
|
||||
})
|
||||
|
||||
# Windows path length ceiling for the classic (non-\\?\) API. A longer path
|
||||
# cannot be opened by the agent anyway, so refusing here is honest.
|
||||
LOG_PATH_MAX_LENGTH = 260
|
||||
|
||||
|
||||
def _validate_log_path(target):
|
||||
"""Reject a collect_log path that cannot be a readable local log file.
|
||||
|
||||
Returns an error string, or None when the shape is acceptable.
|
||||
|
||||
This is a SHAPE check for fast operator feedback. It cannot be a security
|
||||
boundary: the server has no view of the endpoint's filesystem, and any
|
||||
caller reaching this code already holds endpoints.updateapp. The agent
|
||||
performs its own independent validation before opening anything.
|
||||
|
||||
Rejections here are the ones that are wrong no matter what is on disk:
|
||||
a relative path (ambiguous - relative to what, on a service running as
|
||||
SYSTEM), a traversal segment (an attempt to walk out of the directory the
|
||||
operator named, which an honest log path never needs), a UNC path (that
|
||||
would make the endpoint agent read from a file server using the machine
|
||||
account, turning a log collector into a network file reader), and an
|
||||
extension outside the text allowlist.
|
||||
"""
|
||||
path = (target or '').strip()
|
||||
if not path:
|
||||
return 'A log file path is required'
|
||||
|
||||
if len(path) > LOG_PATH_MAX_LENGTH:
|
||||
return (f'Path is too long ({len(path)} characters); the maximum is '
|
||||
f'{LOG_PATH_MAX_LENGTH}')
|
||||
|
||||
normalized = path.replace('/', '\\')
|
||||
|
||||
# A UNC path would have the agent read from a network share as the machine
|
||||
# account. That is a different capability from "read a log off this PC" and
|
||||
# is not what this permission was granted for.
|
||||
if normalized.startswith('\\\\'):
|
||||
return ('UNC/network paths are not supported; collect logs from a '
|
||||
'local path on the endpoint')
|
||||
|
||||
# Require a drive-letter absolute path. A bare or relative path resolves
|
||||
# against the agent service's working directory, which is not somewhere an
|
||||
# operator can reason about.
|
||||
if not re.match(r'^[A-Za-z]:\\', normalized):
|
||||
return ('Enter a full local path including the drive letter '
|
||||
r'(for example C:\ProgramData\App\app.log)')
|
||||
|
||||
# '..' would let a typed path escape the directory it appears to name. No
|
||||
# legitimate log path needs it, so it is refused rather than normalised -
|
||||
# normalising would silently read something other than what was reviewed.
|
||||
if any(part == '..' for part in normalized.split('\\')):
|
||||
return 'Path must not contain ".." segments'
|
||||
|
||||
# Wildcards would make the collection non-deterministic: what it reads would
|
||||
# depend on the directory's contents at run time, so the history could not
|
||||
# say what was actually collected.
|
||||
if '*' in normalized or '?' in normalized:
|
||||
return 'Wildcards are not supported; name a single log file'
|
||||
|
||||
extension = os.path.splitext(normalized)[1].lower()
|
||||
if extension not in LOG_PATH_EXTENSIONS:
|
||||
allowed = ', '.join(sorted(LOG_PATH_EXTENSIONS))
|
||||
return (f'Only plaintext log files can be collected. Allowed '
|
||||
f'extensions: {allowed}')
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _record_log_collection(command, detail, content):
|
||||
"""Persist a collected log for a command and prune that asset's history.
|
||||
|
||||
Called inside report_command_result, before its commit, so the collection
|
||||
row, the pruning and the command's own finalisation all land together. A
|
||||
separate commit here could leave a collected log with no finalised command
|
||||
if the request failed in between.
|
||||
|
||||
Pruning is per asset and runs on insert. Doing it here rather than on a
|
||||
schedule means the cap is a real invariant rather than something that is
|
||||
true most of the time: there is no window in which an asset has 40 rows
|
||||
waiting for a cleanup job that may not be running.
|
||||
"""
|
||||
parsed_modified = None
|
||||
raw_modified = (detail or {}).get('modifiedat')
|
||||
if raw_modified:
|
||||
parsed_modified = _parse_agent_datetime(raw_modified)
|
||||
|
||||
text = content if isinstance(content, str) else None
|
||||
|
||||
collection = EndpointLogCollection(
|
||||
assetid=command.assetid,
|
||||
endpointcommandid=command.endpointcommandid,
|
||||
# Prefer the path the AGENT resolved; fall back to what was queued so a
|
||||
# row is never left without a path to show.
|
||||
sourcepath=((detail or {}).get('path') or command.target or '')[:512],
|
||||
content=text,
|
||||
filesizebytes=_coerce_int((detail or {}).get('filesizebytes')),
|
||||
contentbytes=(len(text.encode('utf-8', 'replace')) if text is not None
|
||||
else None),
|
||||
linecount=_coerce_int((detail or {}).get('linecount')),
|
||||
istruncated=bool((detail or {}).get('truncated')),
|
||||
filemodifiedat=parsed_modified,
|
||||
# requestedby is the operator who queued the collection. The agent posts
|
||||
# this result with a collector token and has no user identity, so the
|
||||
# attribution has to come off the command row.
|
||||
collectedbyuserid=command.requestedby,
|
||||
collectedbyusername=_username_for(command.requestedby),
|
||||
collectedat=_naive_utcnow(),
|
||||
)
|
||||
db.session.add(collection)
|
||||
# Make the new row visible to the pruning query below, so the cap counts it.
|
||||
db.session.flush()
|
||||
|
||||
_prune_log_collections(command.assetid)
|
||||
return collection
|
||||
|
||||
|
||||
def _prune_log_collections(assetid):
|
||||
"""Keep only the newest RETENTION_PER_ASSET collections for an asset.
|
||||
|
||||
Ordered by collectedat DESC then id DESC: two collections can share a
|
||||
timestamp at second resolution, and without the id tiebreak the ordering
|
||||
would be non-deterministic, which is how a prune ends up deleting the row it
|
||||
just inserted.
|
||||
"""
|
||||
keep = EndpointLogCollection.RETENTION_PER_ASSET
|
||||
stale = (EndpointLogCollection.query
|
||||
.filter_by(assetid=assetid)
|
||||
.order_by(EndpointLogCollection.collectedat.desc(),
|
||||
EndpointLogCollection.endpointlogcollectionid.desc())
|
||||
.offset(keep)
|
||||
.all())
|
||||
for row in stale:
|
||||
db.session.delete(row)
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _coerce_int(value):
|
||||
"""Best-effort int from agent-reported JSON; None when unusable."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _username_for(userid):
|
||||
"""Username snapshot for an id, or None. Never raises."""
|
||||
if not userid:
|
||||
return None
|
||||
user = db.session.get(User, userid)
|
||||
return getattr(user, 'username', None) if user is not None else None
|
||||
|
||||
|
||||
def _parse_agent_datetime(value):
|
||||
"""Parse an agent ISO-8601 timestamp into a naive UTC datetime.
|
||||
|
||||
The agent sends an offset-aware string. The column is naive-UTC like every
|
||||
other datetime in this schema, so the offset is applied and then dropped -
|
||||
keeping it would make this the one column that compares wrongly against the
|
||||
rest.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
text = str(value).strip().replace('Z', '+00:00')
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
def _resolve_run_script(target):
|
||||
"""Resolve a run_script target to a script delivery descriptor.
|
||||
|
||||
@@ -2081,6 +2305,8 @@ def _resolve_run_script(target):
|
||||
the claim: an agent that receives it and ignores it is then distinguishable
|
||||
from one that never saw it, which matters while agents of both vintages are
|
||||
in the field. errormode rides the same rule, and for the same reason.
|
||||
captureoutput too: an older agent that never saw the field simply keeps
|
||||
withholding successful output, which is the safe direction to fail.
|
||||
"""
|
||||
kind, _sep, raw = str(target or '').partition(':')
|
||||
script = None
|
||||
@@ -2110,6 +2336,7 @@ def _resolve_run_script(target):
|
||||
'runcontext': script.runcontext,
|
||||
'workingdirectory': script.workingdirectory or '',
|
||||
'errormode': script.errormode or 'default',
|
||||
'captureoutput': bool(script.captureoutput),
|
||||
}, None
|
||||
|
||||
|
||||
@@ -2125,6 +2352,149 @@ def list_endpoint_commands(asset_id: int):
|
||||
return success_response([row.to_dict() for row in rows])
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/scriptruns', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_script_runs(asset_id: int):
|
||||
"""The last few run_script commands for one endpoint, newest first.
|
||||
|
||||
Separate from list_endpoint_commands because the Run Scripts tab is asking a
|
||||
narrower question - "what happened the last few times I pushed a script
|
||||
HERE" - and filtering a 20-row mixed feed client-side would silently show
|
||||
fewer than five script runs whenever other commands are interleaved.
|
||||
|
||||
Carries resultdetail, which the plain command list does not. A script that
|
||||
exits zero produces a green badge and nothing else; the exit code, the
|
||||
context it ran as and the directory it ran in are the only evidence the
|
||||
operator gets that the run did anything at all.
|
||||
"""
|
||||
limit = request.args.get('limit', default=5, type=int) or 5
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
rows = (EndpointCommand.query
|
||||
.filter_by(assetid=asset_id, action='run_script')
|
||||
.order_by(EndpointCommand.createdat.desc())
|
||||
.limit(limit)
|
||||
.all())
|
||||
|
||||
scriptnames = _script_names_for(rows)
|
||||
payload = []
|
||||
for row in rows:
|
||||
data = row.to_dict(includedetail=True)
|
||||
data['scriptname'] = scriptnames.get(row.endpointcommandid)
|
||||
payload.append(data)
|
||||
return success_response(payload)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/logcollections', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_log_collections(asset_id: int):
|
||||
"""Collected-log history for one endpoint, newest first.
|
||||
|
||||
Metadata only - no content. The panel renders up to 20 rows and none of
|
||||
them need the log body, which can be hundreds of kilobytes each; sending
|
||||
them all would make opening the tab far more expensive than reading the one
|
||||
log the operator actually wants.
|
||||
|
||||
Gated on endpoints.view because this is a listing. Reading a body, which is
|
||||
the part that discloses file contents, requires the collect permission - see
|
||||
get_endpoint_log_collection.
|
||||
"""
|
||||
rows = (EndpointLogCollection.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointLogCollection.collectedat.desc(),
|
||||
EndpointLogCollection.endpointlogcollectionid.desc())
|
||||
.limit(EndpointLogCollection.RETENTION_PER_ASSET)
|
||||
.all())
|
||||
return success_response([row.to_dict() for row in rows])
|
||||
|
||||
|
||||
@endpoints_bp.route('/logcollections/<int:collection_id>', methods=['GET'])
|
||||
@require_permission('endpoints.updateapp')
|
||||
def get_endpoint_log_collection(collection_id: int):
|
||||
"""One collected log including its content, for the scrollable pane.
|
||||
|
||||
Deliberately a heavier permission than the list. The list says a log was
|
||||
collected; this returns what was IN it, which is the disclosure. It rides
|
||||
the same permission that authorised the collection, so nobody gains through
|
||||
this route what they could not have collected themselves.
|
||||
"""
|
||||
row = db.session.get(EndpointLogCollection, collection_id)
|
||||
if row is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No log collection {collection_id}',
|
||||
http_code=404)
|
||||
return success_response(row.to_dict(includecontent=True))
|
||||
|
||||
|
||||
@endpoints_bp.route('/logcollections/<int:collection_id>/download',
|
||||
methods=['GET'])
|
||||
@require_permission('endpoints.updateapp')
|
||||
def download_endpoint_log_collection(collection_id: int):
|
||||
"""Serve a collected log as a file attachment.
|
||||
|
||||
Served as text/plain with an explicit attachment disposition so the browser
|
||||
saves it instead of rendering it. That matters beyond convenience: a
|
||||
collected log is untrusted endpoint content, and inline rendering of a file
|
||||
that happens to contain markup would execute it in the app's origin.
|
||||
|
||||
X-Content-Type-Options is set for the same reason - it stops the browser
|
||||
sniffing past the declared text/plain and deciding the bytes are HTML.
|
||||
"""
|
||||
from flask import Response
|
||||
|
||||
row = db.session.get(EndpointLogCollection, collection_id)
|
||||
if row is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No log collection {collection_id}',
|
||||
http_code=404)
|
||||
|
||||
audit_log(
|
||||
action='logcollectiondownloaded',
|
||||
entitytype='Asset',
|
||||
entityid=row.assetid,
|
||||
changes={
|
||||
'sourcepath': row.sourcepath,
|
||||
'endpointlogcollectionid': row.endpointlogcollectionid,
|
||||
'downloadedby': getattr(current_user, 'username', None),
|
||||
},
|
||||
details=f'Downloaded collected log {row.sourcepath}',
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
response = Response(row.content or '', mimetype='text/plain; charset=utf-8')
|
||||
# The filename is already sanitised to [A-Za-z0-9._-] by the model, so it
|
||||
# cannot break out of the quoted header value.
|
||||
response.headers['Content-Disposition'] = (
|
||||
f'attachment; filename="{row.filename}"')
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
return response
|
||||
|
||||
|
||||
def _script_names_for(rows):
|
||||
"""Map command id -> script name for a batch of run_script rows.
|
||||
|
||||
The command stores 'script:<id>', which is not what the operator chose from
|
||||
the picker. Resolved in one query rather than per row. A script deleted
|
||||
since the run leaves the name absent, and the caller falls back to the raw
|
||||
target rather than inventing one.
|
||||
"""
|
||||
wanted = {}
|
||||
for row in rows:
|
||||
kind, _sep, raw = str(row.target or '').partition(':')
|
||||
if kind == 'script' and raw.isdigit():
|
||||
wanted[row.endpointcommandid] = int(raw)
|
||||
if not wanted:
|
||||
return {}
|
||||
|
||||
found = {
|
||||
script.scriptid: script.scriptname
|
||||
for script in Script.query.filter(
|
||||
Script.scriptid.in_(set(wanted.values()))).all()
|
||||
}
|
||||
return {cmdid: found[sid]
|
||||
for cmdid, sid in wanted.items() if sid in found}
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/policyholds', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def list_endpoint_policy_holds(asset_id: int):
|
||||
@@ -2515,6 +2885,29 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action {action!r} does not accept a target')
|
||||
|
||||
# Capability, not authorization: does the agent on THIS endpoint actually
|
||||
# implement the action? An agent older than the action falls through to its
|
||||
# unknown-action default and answers "Unknown or disallowed action", which
|
||||
# reads as a permission refusal and sends the operator to check a grant they
|
||||
# already hold. Checked after the permission and policy gates so an
|
||||
# unauthorized caller still gets the authorization answer rather than a
|
||||
# version hint about a machine they may not act on.
|
||||
#
|
||||
# Deliberately a hard refusal, not a warning: the command is guaranteed to
|
||||
# fail, and queueing it anyway spends the one in-flight command slot and
|
||||
# writes a failed row that looks like the endpoint misbehaved.
|
||||
minagentversion = minimum_agent_version_for(action)
|
||||
if minagentversion:
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
reportedversion = getattr(state, 'agentversion', None)
|
||||
if not agent_meets_minimum(reportedversion, minagentversion):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Action {action!r} requires agent {minagentversion} or newer; '
|
||||
f'this endpoint reports '
|
||||
f'{reportedversion or "no version"}. Update the agent on this '
|
||||
f'endpoint to use it.')
|
||||
|
||||
# update_app pins a specific application version; verify it resolves to a
|
||||
# real installer now so a broken push is rejected at enqueue rather than
|
||||
# failing opaquely on the endpoint minutes later.
|
||||
@@ -2538,6 +2931,18 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, script_error)
|
||||
pinnedsha256 = (script or {}).get('sha256')
|
||||
|
||||
# collect_log reads a caller-supplied path. Validate its SHAPE here so an
|
||||
# obviously wrong path fails immediately with a message the operator can act
|
||||
# on, instead of consuming a poll cycle to come back as a generic endpoint
|
||||
# failure. This is a usability gate, not the security boundary: the agent
|
||||
# re-validates independently, because a server-side check cannot know what
|
||||
# is really on that disk and must never be the only thing standing between a
|
||||
# typed string and a file read.
|
||||
if action == 'collect_log':
|
||||
path_error = _validate_log_path(target)
|
||||
if path_error is not None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, path_error)
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if state is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
@@ -2581,6 +2986,90 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
message='Command queued', http_code=201)
|
||||
|
||||
|
||||
@endpoints_bp.route('/<int:asset_id>/agentupgrade', methods=['POST'])
|
||||
@require_permission('endpoints.agentupdate')
|
||||
def enqueue_agent_upgrade(asset_id: int):
|
||||
"""Push the published agent build to one endpoint.
|
||||
|
||||
Replacing the agent is self-modifying the control plane: the process that
|
||||
receives this command is the one being swapped. It therefore sits behind
|
||||
endpoints.agentupdate rather than endpoints.command or endpoints.deploy -
|
||||
see the permission's rationale in plugin.py.
|
||||
|
||||
This does NOT enqueue directly. It prepares the delivery (packages the
|
||||
current build, seeds/refreshes the upgrade script) and then delegates to
|
||||
enqueue_endpoint_command as a run_script push. Writing a second enqueue
|
||||
path here would duplicate - and eventually drift from - the endpoint lock
|
||||
check, the Configuration Policy gate, stale-command reaping and the
|
||||
at-most-one-in-flight rule. Those are exactly the protections a
|
||||
fleet-wide agent swap must not be the one action to skip.
|
||||
|
||||
Because it lands as run_script, the caller needs endpoints.runscript too
|
||||
(enforced by the delegate as the elevated permission for that action).
|
||||
That is deliberate: this delivers arbitrary SYSTEM PowerShell by the same
|
||||
mechanism as any other script push.
|
||||
"""
|
||||
from . import agentregistry, agentupgradescript
|
||||
|
||||
# Refresh the package and the script body from the CURRENT staged build
|
||||
# before queueing, so an upgrade cannot ship a stale payload if the row was
|
||||
# written before the latest upload. Both operations are idempotent.
|
||||
try:
|
||||
registration = agentregistry.register_current_agent_package()
|
||||
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Could not package the agent build: {exc}')
|
||||
if registration is None:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'No agent build is published. Upload an agent binary before '
|
||||
'pushing an upgrade.')
|
||||
|
||||
try:
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
except Exception as exc: # noqa: BLE001 - surfaced, not swallowed
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Could not prepare the upgrade script: {exc}')
|
||||
if seeded is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'The agent upgrade script could not be prepared.')
|
||||
|
||||
# Rewrite the request body to the equivalent run_script push and hand it to
|
||||
# the real enqueue route. The policyoverride/overridereason fields are
|
||||
# forwarded untouched so break-glass keeps behaving exactly as it does for
|
||||
# a normal script push, including its admin-role and reason requirements.
|
||||
payload = request.get_json(silent=True) or {}
|
||||
forwarded = {
|
||||
'action': 'run_script',
|
||||
'target': 'script:{}'.format(seeded['scriptid']),
|
||||
}
|
||||
if payload.get('policyoverride'):
|
||||
forwarded['policyoverride'] = True
|
||||
forwarded['overridereason'] = payload.get('overridereason') or ''
|
||||
|
||||
# Swap the parsed JSON for the delegate. Flask caches the parsed body on
|
||||
# the request, so setting the cache is what makes get_json() return this.
|
||||
request._cached_json = (forwarded, forwarded)
|
||||
|
||||
response = enqueue_endpoint_command(asset_id)
|
||||
|
||||
# Audit only a push that actually queued. The delegate returns a non-201
|
||||
# for a lock, a policy denial or an in-flight command, and recording those
|
||||
# as upgrades would put events in the log for pushes that never happened.
|
||||
status = getattr(response, 'status_code', None)
|
||||
if status == 201:
|
||||
AuditLog.log(
|
||||
'created', 'AgentUpgrade', entityid=asset_id,
|
||||
entityname='agent {}'.format(registration['agentversion']),
|
||||
details='agent upgrade queued for asset {} (version {}, script {})'
|
||||
.format(asset_id, registration['agentversion'],
|
||||
seeded['scriptid']))
|
||||
db.session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
|
||||
def resolved_monitors_for_agent():
|
||||
"""Agent watch list: the monitors this endpoint should report on.
|
||||
@@ -2763,6 +3252,20 @@ def report_command_result(command_id: int):
|
||||
else EndpointCommand.STATUS_FAILED)
|
||||
command.resultmessage = (payload.get('message') or '')[:2000] or None
|
||||
detail = payload.get('detail')
|
||||
|
||||
# A successful collect_log carries the file body in detail.content. Move it
|
||||
# into its own row and strip it from the detail before that detail is
|
||||
# serialised onto the command: storing it twice would double the cost of the
|
||||
# largest payload this API accepts, and resultjson is read by every command
|
||||
# history list that only wants a status.
|
||||
#
|
||||
# Only on success. A failed collection's detail is small (the reason it
|
||||
# failed) and belongs on the command, where failures are already read.
|
||||
if command.action == 'collect_log' and succeeded and isinstance(detail, dict):
|
||||
detail = dict(detail)
|
||||
content = detail.pop('content', None)
|
||||
_record_log_collection(command, detail, content)
|
||||
|
||||
command.resultjson = json.dumps(detail, separators=(',', ':')) \
|
||||
if detail is not None else None
|
||||
command.completedat = _naive_utcnow()
|
||||
|
||||
332
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
Normal file
332
plugins/endpoints/api/templates/upgrade-shopdbagent.ps1
Normal file
@@ -0,0 +1,332 @@
|
||||
# upgrade-shopdbagent.ps1
|
||||
# ShopDB Endpoint Agent SELF-UPGRADE template. Delivered to an endpoint by the
|
||||
# run_script command and executed by the agent as SYSTEM.
|
||||
#
|
||||
# WHY THIS TAKES NO PARAMETERS
|
||||
# run_script executes "powershell.exe -File <script>" with no argument list, so
|
||||
# there is no channel to pass BaseUrl/CollectorKey in. That is not a problem
|
||||
# here: an upgrade by definition targets an endpoint that is ALREADY installed
|
||||
# and therefore already holds its own config. This script READS that config and
|
||||
# never rewrites it - the values stay exactly as provisioned, so an upgrade can
|
||||
# not silently repoint an endpoint at a different server or key. A fresh
|
||||
# install is still install-shopdbagent.ps1's job.
|
||||
#
|
||||
# WHY IT RE-LAUNCHES ITSELF DETACHED
|
||||
# The agent process is what runs this script, and the upgrade must stop that
|
||||
# same agent to release the lock on shopdbagent.exe (single-file self-contained
|
||||
# exes are held open while running). If the script stayed a child of the agent,
|
||||
# stopping the service would kill the script mid-copy and leave the install
|
||||
# directory half-written. The first pass therefore copies itself to a temp
|
||||
# location, relaunches detached, and exits immediately so the agent sees a
|
||||
# clean, fast result. All real work happens in the detached second pass.
|
||||
#
|
||||
# REPORTING
|
||||
# Success is NOT reported through the command result - by the time the upgrade
|
||||
# succeeds, the agent that would have reported it has been restarted. The new
|
||||
# version is confirmed on the next check-in. This script's own transcript is
|
||||
# the local record; see $LogFile below.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Set only when the script relaunches itself. Operators never pass this.
|
||||
[switch] $Detached
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ServiceName = 'ShopDbEndpointAgent'
|
||||
$InstallDir = 'C:\Program Files\ShopDB\EndpointAgent'
|
||||
$TrayExeName = 'shopdb-endpoint-tray.exe'
|
||||
$AgentExeName = 'shopdbagent.exe'
|
||||
|
||||
$StateDir = Join-Path $env:ProgramData 'ShopDB\EndpointAgent'
|
||||
$WorkDir = Join-Path $StateDir 'upgrade'
|
||||
$LogDir = Join-Path $StateDir 'logs'
|
||||
$LogFile = Join-Path $LogDir ('upgrade-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
|
||||
# Server-relative path of the managed agent bundle. This reuses the EXISTING
|
||||
# agent-facing app-package route rather than adding a parallel download: it
|
||||
# already authenticates with the collector key and already resolves an
|
||||
# application's installpath, which is precisely what upload registration
|
||||
# stamped for the agent bundle. The appid is substituted by the server when
|
||||
# this template is rendered, because it is assigned when the row is created.
|
||||
$PackagePath = '/api/endpoints/apppackage/app/{{AGENT_APPID}}'
|
||||
|
||||
function Write-Log {
|
||||
param([string] $Message, [string] $Level = 'INFO')
|
||||
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Write-Host $line
|
||||
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
|
||||
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
|
||||
# 64-bit agent's config is not, and the upgrade would look unconfigured.
|
||||
function Get-Hklm64Value {
|
||||
param([string] $SubKey, [string] $Name)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey)
|
||||
if (-not $key) { return $null }
|
||||
try { return $key.GetValue($Name) } finally { $key.Dispose() }
|
||||
}
|
||||
finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
# Config resolution mirrors the agent's own order of preference, so the upgrade
|
||||
# reaches the same server the agent is actually talking to. Registry first;
|
||||
# appsettings.json is the fallback for locked-down hosts (CyberArk/EPM) that
|
||||
# gate the HKLM hive against reads.
|
||||
function Get-AgentConfig {
|
||||
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
|
||||
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
|
||||
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
|
||||
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
throw 'No BaseUrl/CollectorKey found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
}
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = $apiKey }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First pass: relaunch detached and get out of the way. See header.
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not $Detached) {
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
|
||||
$selfCopy = Join-Path $WorkDir 'upgrade-shopdbagent.ps1'
|
||||
Copy-Item -Path $PSCommandPath -Destination $selfCopy -Force
|
||||
|
||||
# Copied out of the agent's own temp dir on purpose: the delivered script
|
||||
# may be cleaned up by the agent once the command returns, which would
|
||||
# delete this file out from under the detached run.
|
||||
Start-Process -FilePath 'powershell.exe' `
|
||||
-ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', `
|
||||
'-File', "`"$selfCopy`"", '-Detached' `
|
||||
-WindowStyle Hidden | Out-Null
|
||||
|
||||
Write-Host 'Upgrade handed off to a detached process; this agent will restart shortly.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Second pass (detached): the actual upgrade.
|
||||
# ---------------------------------------------------------------------------
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir, $LogDir | Out-Null
|
||||
Write-Log '== ShopDB Endpoint Agent self-upgrade =='
|
||||
|
||||
try {
|
||||
$config = Get-AgentConfig
|
||||
Write-Log "server: $($config.BaseUrl)"
|
||||
|
||||
# 1. Download BEFORE stopping anything. A failed download must leave the
|
||||
# running agent untouched rather than stranding the endpoint with a
|
||||
# stopped service and no replacement binaries.
|
||||
$zipPath = Join-Path $WorkDir 'agent-package.zip'
|
||||
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
|
||||
$url = $config.BaseUrl + $PackagePath
|
||||
Write-Log "downloading package from $url ..."
|
||||
|
||||
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
|
||||
# protocols the server will refuse.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -Uri $url -Headers @{ 'X-API-Key' = $config.ApiKey } `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
|
||||
$size = (Get-Item $zipPath).Length
|
||||
Write-Log "downloaded $size bytes"
|
||||
if ($size -lt 1MB) {
|
||||
throw "Package is only $size bytes; refusing to install a payload this small."
|
||||
}
|
||||
|
||||
# 2. Unpack to a staging dir and validate the payload BEFORE touching the
|
||||
# install. Expanding straight over Program Files would leave a partially
|
||||
# replaced install if the archive turned out to be short a binary.
|
||||
$stage = Join-Path $WorkDir 'stage'
|
||||
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||
Expand-Archive -Path $zipPath -DestinationPath $stage -Force
|
||||
|
||||
$newAgent = Join-Path $stage $AgentExeName
|
||||
if (-not (Test-Path $newAgent)) {
|
||||
throw "$AgentExeName not found in the downloaded package; aborting before any change."
|
||||
}
|
||||
$newTray = Join-Path $stage $TrayExeName
|
||||
$haveTray = Test-Path $newTray
|
||||
Write-Log ("payload validated (agent{0})" -f $(if ($haveTray) { ' + tray' } else { ', no tray' }))
|
||||
|
||||
# 3. Keep the current binaries so a failed swap can be undone. The config
|
||||
# files are deliberately NOT part of this - they are never modified.
|
||||
$backup = Join-Path $WorkDir 'backup'
|
||||
if (Test-Path $backup) { Remove-Item $backup -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $backup | Out-Null
|
||||
foreach ($name in @($AgentExeName, $TrayExeName)) {
|
||||
$existing = Join-Path $InstallDir $name
|
||||
if (Test-Path $existing) { Copy-Item $existing -Destination $backup -Force }
|
||||
}
|
||||
Write-Log 'backed up current binaries'
|
||||
|
||||
# 4. Stop the service and kill lingering processes. Until this completes
|
||||
# the exes are locked and the copy would fail with a file-in-use error.
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -ne 'Stopped') {
|
||||
Write-Log "stopping service $ServiceName ..."
|
||||
try { Stop-Service -Name $ServiceName -Force -ErrorAction Stop }
|
||||
catch { Write-Log "could not stop service: $($_.Exception.Message)" 'WARN' }
|
||||
}
|
||||
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
foreach ($name in @('shopdbagent', $trayBase)) {
|
||||
foreach ($p in (Get-Process -Name $name -ErrorAction SilentlyContinue)) {
|
||||
Write-Log "killing $($p.ProcessName) (pid $($p.Id))"
|
||||
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 750
|
||||
|
||||
# 5. Swap the binaries. On failure, restore the backup and restart, so a
|
||||
# broken upgrade leaves a RUNNING old agent rather than a dead endpoint
|
||||
# that can no longer be reached to fix remotely.
|
||||
try {
|
||||
Copy-Item -Path $newAgent -Destination $InstallDir -Force
|
||||
if ($haveTray) { Copy-Item -Path $newTray -Destination $InstallDir -Force }
|
||||
Write-Log 'binaries replaced'
|
||||
}
|
||||
catch {
|
||||
Write-Log "copy failed: $($_.Exception.Message); rolling back" 'ERROR'
|
||||
Get-ChildItem $backup -File | ForEach-Object {
|
||||
Copy-Item $_.FullName -Destination $InstallDir -Force
|
||||
}
|
||||
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
|
||||
# 6. Clear Mark-of-the-Web. Files extracted from a downloaded archive carry
|
||||
# a Zone.Identifier stream; SmartScreen blocks the unsigned exe on first
|
||||
# run, which would leave the service unable to start.
|
||||
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
|
||||
try { Unblock-File -Path $_.FullName -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
# 7. Restart and verify it stays up. A service that starts and immediately
|
||||
# faults is a failed upgrade, so confirm rather than assume.
|
||||
Write-Log "starting service $ServiceName ..."
|
||||
Start-Service -Name $ServiceName
|
||||
Start-Sleep -Seconds 5
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if (-not $svc -or $svc.Status -ne 'Running') {
|
||||
Write-Log 'service did not stay running; rolling back' 'ERROR'
|
||||
Get-ChildItem $backup -File | ForEach-Object {
|
||||
Copy-Item $_.FullName -Destination $InstallDir -Force
|
||||
}
|
||||
Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
throw 'Upgraded service failed to start; rolled back to the previous build.'
|
||||
}
|
||||
|
||||
$installed = (Get-Item (Join-Path $InstallDir $AgentExeName)).VersionInfo.FileVersion
|
||||
Write-Log "upgrade complete; agent version now $installed"
|
||||
|
||||
# 8. Relaunch the tray for the interactive user.
|
||||
#
|
||||
# This CANNOT use the installer's `explorer.exe <path>` trick. That works in
|
||||
# install-shopdbagent.ps1 only because the installer runs AS THE USER - note
|
||||
# its own guard, `if ($interactiveUser -ne "SYSTEM")`. This script always
|
||||
# runs as SYSTEM in session 0, where `explorer.exe <path>` hands the request
|
||||
# to a shell that is not reachable from here and then exits 0. Start-Process
|
||||
# would report success and we would log a relaunch that never happened,
|
||||
# which is exactly the bug this replaces.
|
||||
#
|
||||
# It also must NOT descend into the session with a token-duplicating launch.
|
||||
# UserContextSpool documents that WTSQueryUserToken is a privileged operation
|
||||
# that endpoint protection blocks on these hardened hosts - the service tried
|
||||
# that direction before and it produced nothing on a live endpoint.
|
||||
#
|
||||
# So the tray is started the same way its own data path is pinned: against
|
||||
# the known ShopFloor account. Task Scheduler performs the session/token
|
||||
# transition, and an Interactive principal with no RunLevel starts the tray
|
||||
# at the user's normal integrity - preserving the de-elevation the installer
|
||||
# wanted, because an elevated unsigned tray trips the CyberArk/EPM broker.
|
||||
#
|
||||
# Best-effort by design: the agent is already upgraded and running, and the
|
||||
# HKLM Run key the installer wrote still starts the tray at next logon. A
|
||||
# late tray is not worth failing - or rolling back - a good agent upgrade.
|
||||
if ($haveTray) {
|
||||
$trayPath = Join-Path $InstallDir $TrayExeName
|
||||
# Same pinned account the spool uses (FileLoggerProvider.ShopFloorUserName):
|
||||
# every shop-floor endpoint signs in as this local account, and the tray
|
||||
# must run as it or the mapped-drive probe reports the wrong account's
|
||||
# (empty) drive table. Only relaunch if that profile is actually present.
|
||||
$trayUser = 'ShopFloor'
|
||||
$trayUserProfile = Join-Path $env:SystemDrive (Join-Path 'Users' $trayUser)
|
||||
if (Test-Path $trayUserProfile) {
|
||||
$taskName = 'ShopDB tray upgrade relaunch'
|
||||
try {
|
||||
# Clear a leftover from an interrupted earlier upgrade so we do
|
||||
# not stack duplicates.
|
||||
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $trayPath
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $trayUser -LogonType Interactive
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
|
||||
# Verify rather than assume. The old code logged success off a
|
||||
# Start-Process that could not fail, so a missing tray looked
|
||||
# like a clean upgrade. Poll for the real process instead.
|
||||
$trayBaseName = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
$running = $null
|
||||
for ($i = 0; $i -lt 10; $i++) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$running = Get-Process -Name $trayBaseName -ErrorAction SilentlyContinue
|
||||
if ($running) { break }
|
||||
}
|
||||
if ($running) { Write-Log "tray relaunched as $trayUser" }
|
||||
else {
|
||||
Write-Log ('tray did not appear after relaunch; it will start at next ' +
|
||||
'logon via the HKLM Run key') 'WARN'
|
||||
}
|
||||
|
||||
# One-shot hand-off, not a persistent artifact: leaving it would
|
||||
# surface in the uninstaller's leftover sweep and in task audits.
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
Write-Log ("could not relaunch tray: $($_.Exception.Message); " +
|
||||
'it will start at next logon') 'WARN'
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log ("no $trayUser profile at $trayUserProfile; " +
|
||||
'leaving the tray to start at next logon') 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "UPGRADE FAILED: $($_.Exception.Message)" 'ERROR'
|
||||
exit 1
|
||||
}
|
||||
@@ -110,6 +110,32 @@ COMMAND_ALLOWLIST = {
|
||||
'PCDetail.vue).',
|
||||
'targetkind': 'script',
|
||||
},
|
||||
'collect_log': {
|
||||
'label': 'Collect log file',
|
||||
'description': 'Read a plaintext log file from a known path on the '
|
||||
'endpoint and return its contents to ShopDB for '
|
||||
'viewing and download. Read-only: the agent opens the '
|
||||
'file for reading with sharing permitted (so a log a '
|
||||
'running application holds open can still be read), '
|
||||
'never writes, and never deletes. Only the TAIL of a '
|
||||
'file is returned, and only for plaintext extensions - '
|
||||
'a binary or oversized file is refused rather than '
|
||||
'mangled. Gated on endpoints.updateapp because the '
|
||||
'reason to collect a log is almost always to diagnose '
|
||||
'an application push, so it belongs with app lifecycle '
|
||||
'rights rather than as a separate grant. Note this '
|
||||
'still reads a caller-supplied path as SYSTEM: it is '
|
||||
'bounded to text files, but it is not a sandbox.',
|
||||
'targetkind': 'logpath',
|
||||
# Introduced in agent 1.16.0. An older agent has no case for this action
|
||||
# and falls through to its unknown-action default, which answers
|
||||
# "Unknown or disallowed action" - a message that reads as an
|
||||
# authorization refusal and sends the operator looking at permissions
|
||||
# they already hold. Declaring the floor here lets the enqueue route
|
||||
# refuse first, naming both versions, so the answer is the actionable
|
||||
# one: this endpoint needs a newer agent.
|
||||
'minagentversion': '1.16.0',
|
||||
},
|
||||
'update_app': {
|
||||
'label': 'Install/update application',
|
||||
'description': 'Push a ShopDB-hosted installer for an application (by app '
|
||||
@@ -192,11 +218,18 @@ COMMAND_ALLOWLIST = {
|
||||
# update_app - runs a pinned installer
|
||||
# run_script - arbitrary code as SYSTEM unless the descriptor pins a
|
||||
# user context
|
||||
#
|
||||
# collect_log rides endpoints.updateapp rather than taking a grant of its own.
|
||||
# The reason to read a log is nearly always to find out why an app push behaved
|
||||
# the way it did, so the right to install an application and the right to read
|
||||
# that application's log are the same job. It is read-only and strictly weaker
|
||||
# than the installer push the same permission already allows.
|
||||
ELEVATED_ACTION_PERMISSIONS = {
|
||||
'system_restart': 'endpoints.restart',
|
||||
'scheduledtask_run': 'endpoints.corptools',
|
||||
'run_script': 'endpoints.runscript',
|
||||
'update_app': 'endpoints.updateapp',
|
||||
'collect_log': 'endpoints.updateapp',
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +238,70 @@ def elevated_permission_for(action):
|
||||
return ELEVATED_ACTION_PERMISSIONS.get(action)
|
||||
|
||||
|
||||
def minimum_agent_version_for(action):
|
||||
"""The lowest agent version that implements an action, or None if any will do."""
|
||||
return COMMAND_ALLOWLIST.get(action, {}).get('minagentversion')
|
||||
|
||||
|
||||
def action_minimum_versions():
|
||||
"""Every action that has an agent version floor, as {action: version}.
|
||||
|
||||
Actions without a floor are omitted rather than mapped to None: a caller
|
||||
asking "is there a minimum for this?" gets a plain absent key, and the
|
||||
payload stays small as the allowlist grows.
|
||||
"""
|
||||
return {
|
||||
action: spec['minagentversion']
|
||||
for action, spec in COMMAND_ALLOWLIST.items()
|
||||
if spec.get('minagentversion')
|
||||
}
|
||||
|
||||
|
||||
def parse_agent_version(value):
|
||||
"""A reported agent version as a comparable tuple, or None if unusable.
|
||||
|
||||
Agents report four parts ('1.15.0.0') while the csproj and the binary store
|
||||
carry three ('1.16.0'), so a plain string compare puts a NEWER agent below
|
||||
the floor it satisfies. Both are normalised to a tuple and compared
|
||||
numerically, with missing trailing parts treated as zero, so '1.16.0' and
|
||||
'1.16.0.0' are the same version.
|
||||
|
||||
Anything non-numeric returns None rather than a guess. The fleet carries
|
||||
'0.0.0-sample' on seeded rows, and there is no honest ordering between a
|
||||
placeholder and a real build - the caller decides what to do about an
|
||||
unknown version, which is a different question from being out of date.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
parts = []
|
||||
for chunk in str(value).strip().split('.'):
|
||||
if not chunk.isdigit():
|
||||
return None
|
||||
parts.append(int(chunk))
|
||||
if not parts:
|
||||
return None
|
||||
while len(parts) < 4:
|
||||
parts.append(0)
|
||||
return tuple(parts[:4])
|
||||
|
||||
|
||||
def agent_meets_minimum(reported, minimum):
|
||||
"""Whether a reported agent version satisfies a minimum.
|
||||
|
||||
Returns False when either side is unparseable. That is the fail-closed
|
||||
direction: an endpoint whose version we cannot read is one we cannot claim
|
||||
supports the action, and letting it through only moves the failure to the
|
||||
agent where the error message is worse.
|
||||
"""
|
||||
if not minimum:
|
||||
return True
|
||||
reportedparts = parse_agent_version(reported)
|
||||
minimumparts = parse_agent_version(minimum)
|
||||
if reportedparts is None or minimumparts is None:
|
||||
return False
|
||||
return reportedparts >= minimumparts
|
||||
|
||||
|
||||
# Maps a monitored services/processes row to the start/stop/status actions that
|
||||
# control it, so the services panel can render an inline toggle on exactly the
|
||||
# rows that have a registered command action. The key is the agent-reported
|
||||
@@ -339,6 +436,19 @@ POLICY_EXEMPT_ACTIONS = frozenset({
|
||||
# policy is non-empty - a denial no operator can act on, because the fix
|
||||
# lives in a policy that was never about this action.
|
||||
'collect_now',
|
||||
# Read-only diagnostic retrieval. Policy describes how a machine should be
|
||||
# shaped - which monitors run, which service primitives are reachable - and
|
||||
# reading a log file shapes nothing. An existing policy was authored before
|
||||
# this action existed and so can never mention it, which under the
|
||||
# intersection means every endpoint carrying any policy at all refuses log
|
||||
# collection, including for an admin. That denial is also unactionable in
|
||||
# the way that matters: logs are what an operator reaches for when an
|
||||
# endpoint is already misbehaving, so gating them behind editing that same
|
||||
# endpoint's policy withholds the evidence exactly when it is needed.
|
||||
# Exemption from POLICY is not exemption from PERMISSION - collect_log
|
||||
# still requires endpoints.updateapp via ELEVATED_ACTION_PERMISSIONS, and
|
||||
# the agent still refuses UNC, traversal, directories and non-text files.
|
||||
'collect_log',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for the staged agent rollout. Shown only to operators who
|
||||
hold the grant it will require, so it reads as "not built yet" to the
|
||||
people waiting on it rather than as a missing permission to everyone
|
||||
else. This page is due a refactor; the utility lands with that work. -->
|
||||
<div v-if="canagentupdate" class="rollout-note">
|
||||
Bulk Agent Update utility coming soon. Until then, agents update through
|
||||
the existing installer push, and an endpoint behind the published build is
|
||||
flagged on its own detail page.
|
||||
</div>
|
||||
|
||||
<!-- Actions live here rather than per row. A tenth column of buttons
|
||||
pushed itself off the edge of a nowrap table, and selecting one row
|
||||
costs a single click while giving one command path instead of two. -->
|
||||
@@ -194,6 +204,9 @@ const toast = useToast()
|
||||
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
|
||||
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
|
||||
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
|
||||
// Reserved for the staged rollout. Agent replacement is the control plane
|
||||
// modifying itself, so it stays separate from ordinary app deployment.
|
||||
const canagentupdate = computed(() => authStore.hasPermission('endpoints.agentupdate'))
|
||||
|
||||
const allselected = computed(() =>
|
||||
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
|
||||
@@ -515,6 +528,18 @@ function changePerPage(newPerPage) {
|
||||
|
||||
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
||||
|
||||
/* Deliberately quieter than .bulk-bar: this announces something that does not
|
||||
exist yet, so it must not compete with controls that actually do work. */
|
||||
.rollout-note {
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.select-cell { width: 1%; }
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,13 @@
|
||||
<span v-else class="badge badge-warning" title="A failing line does not fail the command">
|
||||
Continue
|
||||
</span>
|
||||
<span
|
||||
v-if="s.captureoutput"
|
||||
class="badge badge-secondary"
|
||||
title="A successful run returns its output to ShopDB"
|
||||
>
|
||||
Returns output
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="s.hasbody" class="badge badge-secondary">{{ s.filename }}</span>
|
||||
@@ -213,6 +220,27 @@
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.captureoutput" type="checkbox" />
|
||||
Return output to ShopDB on success
|
||||
</label>
|
||||
<details class="field-help">
|
||||
<summary>How this works</summary>
|
||||
<p>
|
||||
A failed run always sends its output back, so you can diagnose
|
||||
it from the Run Scripts history without going to the machine.
|
||||
A successful run does not, unless you tick this.
|
||||
</p>
|
||||
<p>
|
||||
Leave it off for scripts whose output could contain anything
|
||||
sensitive - account names, share paths, anything a Write-Host
|
||||
left behind. Turn it on for scripts where seeing the output is
|
||||
the only way to tell the run did real work. Either way the
|
||||
full output is written to the agent log on the endpoint.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input v-model="form.isactive" type="checkbox" />
|
||||
@@ -259,6 +287,7 @@ function emptyForm() {
|
||||
scriptdescription: '',
|
||||
workingdirectory: '',
|
||||
errormode: 'default',
|
||||
captureoutput: false,
|
||||
isactive: true
|
||||
}
|
||||
}
|
||||
@@ -293,6 +322,7 @@ function openModal(script = null) {
|
||||
scriptdescription: script.scriptdescription || '',
|
||||
workingdirectory: script.workingdirectory || '',
|
||||
errormode: script.errormode || 'default',
|
||||
captureoutput: !!script.captureoutput,
|
||||
isactive: script.isactive
|
||||
}
|
||||
: emptyForm()
|
||||
@@ -314,6 +344,7 @@ async function save() {
|
||||
scriptdescription: form.value.scriptdescription,
|
||||
workingdirectory: form.value.workingdirectory,
|
||||
errormode: form.value.errormode,
|
||||
captureoutput: form.value.captureoutput,
|
||||
isactive: form.value.isactive
|
||||
}
|
||||
let scriptid
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from .endpoint import (
|
||||
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
|
||||
EndpointLock,
|
||||
EndpointLock, EndpointLogCollection,
|
||||
)
|
||||
from .monitor import EndpointMonitor
|
||||
from .operatorreport import OperatorReport
|
||||
@@ -12,7 +12,7 @@ from .health import (
|
||||
|
||||
__all__ = [
|
||||
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
|
||||
'EndpointLock',
|
||||
'EndpointLock', 'EndpointLogCollection',
|
||||
'EndpointMonitor', 'OperatorReport',
|
||||
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
|
||||
]
|
||||
|
||||
@@ -16,13 +16,23 @@ Two shapes, one per concern:
|
||||
crash) so this stays a readable security/stability history
|
||||
rather than a raw event firehose.
|
||||
|
||||
EndpointLogCollection
|
||||
A plaintext log file collected from an endpoint on operator
|
||||
demand. Kept out of EndpointCommand.resultjson because it is
|
||||
bulky, needs its own per-asset retention cap, and has to be
|
||||
addressable by id for the download route.
|
||||
|
||||
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
|
||||
is the same physical thing as its Computer/Asset - no duplicate identity. The
|
||||
agent reports a hostname; the plugin resolves it to the existing Computer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
@@ -275,8 +285,16 @@ class EndpointCommand(db.Model):
|
||||
def __repr__(self):
|
||||
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
def to_dict(self, includedetail=False):
|
||||
"""Serialise a command for the operator views.
|
||||
|
||||
resultjson is opt-in. It is agent-reported structured detail, and for a
|
||||
script it carries the evidence that a green badge alone does not: the
|
||||
exit code, the context it ran as and the directory it really ran in. On
|
||||
failure the agent also puts the captured output in there, so it is not
|
||||
served to every list that only needs a status.
|
||||
"""
|
||||
data = {
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'assetid': self.assetid,
|
||||
'action': self.action,
|
||||
@@ -289,6 +307,24 @@ class EndpointCommand(db.Model):
|
||||
'claimedat': _utciso(self.claimedat),
|
||||
'completedat': _utciso(self.completedat),
|
||||
}
|
||||
if includedetail:
|
||||
data['resultdetail'] = self._resultdetail()
|
||||
return data
|
||||
|
||||
def _resultdetail(self):
|
||||
"""resultjson as a dict, or None.
|
||||
|
||||
Stored as text, so a malformed or truncated payload is possible. A
|
||||
detail panel is diagnostics: failing to parse it must not break the
|
||||
command list that surrounds it.
|
||||
"""
|
||||
if not self.resultjson:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(self.resultjson)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
class EndpointEvent(db.Model):
|
||||
@@ -531,3 +567,130 @@ class EndpointLock(db.Model):
|
||||
'releasedat': _utciso(self.releasedat),
|
||||
'releasedbyusername': self.releasedbyusername,
|
||||
}
|
||||
|
||||
|
||||
class EndpointLogCollection(db.Model):
|
||||
"""One plaintext log file collected from an endpoint by an operator.
|
||||
|
||||
Collected content lives here rather than in EndpointCommand.resultjson for
|
||||
three reasons. It is bulky and would be dragged along by every command
|
||||
history list that only wants a status; it needs its own retention rule
|
||||
(RETENTION_PER_ASSET), which a command row cannot express without deleting
|
||||
the audit record of the command itself; and the download route needs to
|
||||
serve bytes, which means it needs a row it can address by id rather than a
|
||||
JSON field it has to re-parse.
|
||||
|
||||
Retention is deliberately PER ASSET. A global cap would let one busy machine
|
||||
evict another machine's history, and the history is read per machine on the
|
||||
PC detail page, so a global cap would make the panel unreliable exactly
|
||||
where it is used. Pruning happens on insert in the result route.
|
||||
|
||||
The content stored here is a TAIL, not necessarily the whole file - see
|
||||
istruncated. A log that has run for months is not something to load into a
|
||||
browser pane, and the agent bounds what it sends.
|
||||
"""
|
||||
|
||||
__tablename__ = 'endpointlogcollections'
|
||||
|
||||
# How many collections to keep per asset. The operator asked for 20: enough
|
||||
# to compare a handful of runs against each other, few enough that stored
|
||||
# log text does not grow without bound across a fleet.
|
||||
RETENTION_PER_ASSET = 20
|
||||
|
||||
endpointlogcollectionid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# The command that produced this, kept for audit: it ties the collected
|
||||
# bytes back to who queued the collection and when it ran. SET NULL rather
|
||||
# than CASCADE so pruning or expiring a command never silently destroys a
|
||||
# log an operator is still looking at.
|
||||
endpointcommandid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('endpointcommands.endpointcommandid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# The path as the AGENT resolved it, which is not always the path the
|
||||
# operator typed (environment variables get expanded endpoint-side). Storing
|
||||
# the resolved one is what makes the history honest about what was read.
|
||||
sourcepath = db.Column(db.String(512), nullable=False)
|
||||
|
||||
content = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Size of the file ON DISK, not the size of content. When the tail is
|
||||
# truncated these differ, and that difference is the whole point: it tells
|
||||
# the operator they are looking at the end of something larger.
|
||||
filesizebytes = db.Column(db.BigInteger, nullable=True)
|
||||
contentbytes = db.Column(db.Integer, nullable=True)
|
||||
linecount = db.Column(db.Integer, nullable=True)
|
||||
istruncated = db.Column(db.Boolean, nullable=False,
|
||||
default=False, server_default=sa.false())
|
||||
|
||||
# Last-write time reported by the endpoint. A log that has not changed since
|
||||
# the previous collection is the fastest way to see an app is not logging.
|
||||
filemodifiedat = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Username snapshot alongside the id: the id alone becomes unreadable if the
|
||||
# user is later removed, and this history is read long after collection.
|
||||
collectedbyuserid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.userid', ondelete='SET NULL'),
|
||||
nullable=True,
|
||||
)
|
||||
collectedbyusername = db.Column(db.String(100), nullable=True)
|
||||
collectedat = db.Column(db.DateTime, nullable=False,
|
||||
default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Serves both hot paths: listing an asset's history newest-first, and
|
||||
# finding the oldest rows to prune on insert.
|
||||
db.Index('idx_endpointlogcollections_assetcollected',
|
||||
'assetid', 'collectedat'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (f"<EndpointLogCollection asset={self.assetid} "
|
||||
f"{self.sourcepath}>")
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""A download filename derived from the collected path."""
|
||||
raw = (self.sourcepath or '').replace('/', '\\')
|
||||
base = raw.rsplit('\\', 1)[-1].strip() or 'collected.log'
|
||||
# Keep it to characters that survive a Content-Disposition header and a
|
||||
# Windows save dialog without quoting games.
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '_', base)[:100]
|
||||
stamp = (self.collectedat or datetime.utcnow()).strftime('%Y%m%d-%H%M%S')
|
||||
return f'{stamp}_{safe}'
|
||||
|
||||
def to_dict(self, includecontent=False):
|
||||
"""Serialise a collection.
|
||||
|
||||
Content is opt-in for the same reason resultjson is on EndpointCommand:
|
||||
the history list renders 20 rows and none of them need the log body,
|
||||
which may be hundreds of kilobytes each.
|
||||
"""
|
||||
data = {
|
||||
'endpointlogcollectionid': self.endpointlogcollectionid,
|
||||
'assetid': self.assetid,
|
||||
'endpointcommandid': self.endpointcommandid,
|
||||
'sourcepath': self.sourcepath,
|
||||
'filesizebytes': self.filesizebytes,
|
||||
'contentbytes': self.contentbytes,
|
||||
'linecount': self.linecount,
|
||||
'istruncated': bool(self.istruncated),
|
||||
'filemodifiedat': _utciso(self.filemodifiedat),
|
||||
'collectedbyuserid': self.collectedbyuserid,
|
||||
'collectedbyusername': self.collectedbyusername,
|
||||
'collectedat': _utciso(self.collectedat),
|
||||
'filename': self.filename,
|
||||
}
|
||||
if includecontent:
|
||||
data['content'] = self.content or ''
|
||||
return data
|
||||
|
||||
@@ -206,7 +206,8 @@ class EndpointsPlugin(BasePlugin):
|
||||
'already registered and allowlisted on the agent)', 'endpoints'),
|
||||
('endpoints.updateapp',
|
||||
'Push application installers to endpoints (runs a pinned '
|
||||
'installer as SYSTEM)', 'endpoints'),
|
||||
'installer as SYSTEM) and collect plaintext log files from '
|
||||
'endpoints by path (read-only, text files only)', 'endpoints'),
|
||||
# Blunt on purpose. This is not "system-level changes" - it is
|
||||
# arbitrary code execution as SYSTEM unless the script descriptor
|
||||
# pins a user context. Whoever grants it must see that here.
|
||||
@@ -503,7 +504,12 @@ class EndpointsPlugin(BasePlugin):
|
||||
},
|
||||
'meta': [
|
||||
{'key': 'detail'},
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||
# datetime, not date: 'date' renders via
|
||||
# toLocaleDateString and drops the clock time entirely,
|
||||
# which made every event on a given day look
|
||||
# simultaneous. Ordering a boot against the logon that
|
||||
# followed it is the whole point of this feed.
|
||||
{'key': 'eventtime', 'label': 'At', 'format': 'datetime'},
|
||||
],
|
||||
# Expands in place on click. The message body is several
|
||||
# hundred words of Windows boilerplate around the one or
|
||||
|
||||
@@ -99,6 +99,39 @@ def collector_authorized(pluginname: str):
|
||||
from shopdb.core.api.collector import _check_collector_auth, _plugin_api_key
|
||||
return _check_collector_auth(_plugin_api_key(pluginname), pluginname)
|
||||
|
||||
|
||||
def application_package_location():
|
||||
"""Where application installers live, and the URL prefix that serves them.
|
||||
|
||||
Returns (directory, urlprefix). A plugin that registers an installable
|
||||
payload (the endpoint agent bundle, for example) must put the file exactly
|
||||
where the applications API expects it and build installpath the same way,
|
||||
or update_app cannot resolve the download. Reconstructing either by hand
|
||||
lets the two drift silently, so both come from one place.
|
||||
|
||||
Imported lazily for the same reason as collector_authorized above:
|
||||
shopdb.core.api imports back into shopdb.api, so a module-level import
|
||||
would be circular at load time.
|
||||
"""
|
||||
from shopdb.core.api.applications import (
|
||||
_apppackage_dir, APP_PACKAGE_URL_PREFIX)
|
||||
return _apppackage_dir(), APP_PACKAGE_URL_PREFIX
|
||||
|
||||
|
||||
def script_body_location():
|
||||
"""Where script bodies live, and the URL prefix that serves them.
|
||||
|
||||
Returns (directory, urlprefix). Same reasoning as
|
||||
application_package_location: a plugin that seeds a script into the
|
||||
library must write the body where the download route reads it from, and
|
||||
reconstructing that path by hand lets the two drift apart silently.
|
||||
|
||||
Imported lazily to avoid the shopdb.core.api -> shopdb.api cycle.
|
||||
"""
|
||||
from shopdb.core.api.scripts import _script_dir, SCRIPT_URL_PREFIX
|
||||
return _script_dir(), SCRIPT_URL_PREFIX
|
||||
|
||||
|
||||
# Serving an uploaded file safely. A plugin that serves user-supplied bytes must
|
||||
# not have to remember the headers that keep an SVG or an HTML file from running
|
||||
# as script in this origin - see shopdb/utils/uploads.py.
|
||||
@@ -388,6 +421,8 @@ __all__ = [
|
||||
'list_service_tokens',
|
||||
'revoke_service_tokens',
|
||||
'collector_authorized',
|
||||
'application_package_location',
|
||||
'script_body_location',
|
||||
'SupportTeam',
|
||||
# Serving uploads
|
||||
'send_upload',
|
||||
|
||||
@@ -112,6 +112,27 @@ def _validate_errormode(value):
|
||||
return value, None
|
||||
|
||||
|
||||
def _validate_captureoutput(value):
|
||||
"""Normalize the "send output back on success" flag. Returns (value, error).
|
||||
|
||||
Absent means False: a script authored before this field existed never
|
||||
returned its successful output to the server, and reading an omitted field
|
||||
as True would start persisting output from bodies nobody reviewed with that
|
||||
question in mind. Strings are accepted because the field arrives from a
|
||||
form control that may send 'true'/'false' rather than a JSON boolean.
|
||||
"""
|
||||
if value is None:
|
||||
return False, None
|
||||
if isinstance(value, bool):
|
||||
return value, None
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in ('true', '1', 'yes'):
|
||||
return True, None
|
||||
if normalized in ('false', '0', 'no', ''):
|
||||
return False, None
|
||||
return None, 'captureoutput must be true or false'
|
||||
|
||||
|
||||
def _validate_workingdirectory(value):
|
||||
"""Normalize the optional folder a script runs in. Returns (value, error).
|
||||
|
||||
@@ -176,6 +197,9 @@ def create_script():
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
errormode, err = _validate_errormode(data.get('errormode'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
|
||||
@@ -185,6 +209,7 @@ def create_script():
|
||||
runcontext=runcontext,
|
||||
workingdirectory=workingdirectory,
|
||||
errormode=errormode,
|
||||
captureoutput=captureoutput,
|
||||
)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
@@ -192,7 +217,8 @@ def create_script():
|
||||
entityname=name,
|
||||
changes={'runcontext': {'new': runcontext},
|
||||
'workingdirectory': {'new': workingdirectory},
|
||||
'errormode': {'new': errormode}})
|
||||
'errormode': {'new': errormode},
|
||||
'captureoutput': {'new': captureoutput}})
|
||||
db.session.commit()
|
||||
return success_response(script.to_dict(), message='Script created', http_code=201)
|
||||
|
||||
@@ -233,6 +259,18 @@ def update_script(script_id: int):
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
script.errormode = errormode
|
||||
# Audited on change: this decides whether a successful run's output is
|
||||
# persisted server-side, so who turned it on is worth being able to answer.
|
||||
if 'captureoutput' in data:
|
||||
captureoutput, err = _validate_captureoutput(data.get('captureoutput'))
|
||||
if err:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, err)
|
||||
if captureoutput != script.captureoutput:
|
||||
AuditLog.log('updated', 'Script', entityid=script.scriptid,
|
||||
entityname=script.scriptname,
|
||||
changes={'captureoutput': {'old': script.captureoutput,
|
||||
'new': captureoutput}})
|
||||
script.captureoutput = captureoutput
|
||||
if 'isactive' in data:
|
||||
script.isactive = bool(data.get('isactive'))
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ body never lives on the endpoint ahead of time and never crosses the command
|
||||
wire; the agent downloads it with its collector credential and verifies sha256.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
@@ -69,6 +71,17 @@ class Script(BaseModel):
|
||||
# existed reads as the permissive behavior it was authored under.
|
||||
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
|
||||
server_default=ERRORMODE_DEFAULT)
|
||||
# Whether a SUCCESSFUL run sends its output back to the server. Failures
|
||||
# always do: a failed run has to be diagnosable. Success is opt-in because
|
||||
# script output carries whatever the body printed - hostnames, paths, a
|
||||
# token an author echoed while debugging - and a run nobody needs to
|
||||
# diagnose is not worth that exposure by default. The endpoint log keeps the
|
||||
# full copy either way, so this only controls what crosses to the server.
|
||||
#
|
||||
# Off by default so existing scripts keep the behavior they were authored
|
||||
# under, same rule as errormode above.
|
||||
captureoutput = db.Column(db.Boolean, default=False, nullable=False,
|
||||
server_default=sa.false())
|
||||
|
||||
def to_dict(self):
|
||||
result = super().to_dict()
|
||||
|
||||
@@ -58,7 +58,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'endpointapplications', 'endpointmonitors',
|
||||
'operatorreports', 'endpointhealthstates',
|
||||
'endpointhealthtransitions', 'endpointmonitorimpacts',
|
||||
'endpointlocks'),
|
||||
'endpointlocks', 'endpointlogcollections'),
|
||||
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
|
||||
129
tests/test_core/test_script_captureoutput.py
Normal file
129
tests/test_core/test_script_captureoutput.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""The per-script "return output on success" flag.
|
||||
|
||||
A failed run has always sent its output back, because a failure has to be
|
||||
diagnosable from the UI. A successful run has not, and that is the behaviour
|
||||
being made opt-in here rather than simply reversed: script output contains
|
||||
whatever the body printed, and persisting that server-side for every routine
|
||||
green run is exposure nobody asked for.
|
||||
|
||||
The load-bearing property is the default. Every script authored before this
|
||||
field existed ran under "success stays silent", so an absent field must never
|
||||
be read as consent.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(client, db):
|
||||
from shopdb.core.models import User
|
||||
|
||||
Permission.seed_entries([SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
user = User(
|
||||
username='captureauthor',
|
||||
email='captureauthor@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.directpermissions.append(
|
||||
Permission.query.filter_by(name='endpoints.runscript').one())
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'captureauthor', 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
|
||||
|
||||
def _create(client, headers, name, **extra):
|
||||
payload = {'scriptname': name}
|
||||
payload.update(extra)
|
||||
return client.post('/api/scripts', headers=headers, json=payload)
|
||||
|
||||
|
||||
def test_a_script_without_the_flag_withholds_output(client, headers):
|
||||
"""The load-bearing test. An omitted field is not consent to persist a
|
||||
successful run's output server-side."""
|
||||
created = _create(client, headers, 'no-capture')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['captureoutput'] is False
|
||||
|
||||
|
||||
def test_the_flag_can_be_turned_on(client, headers):
|
||||
created = _create(client, headers, 'chatty-one', captureoutput=True)
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['captureoutput'] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sent,expected', [
|
||||
('true', True),
|
||||
('false', False),
|
||||
(True, True),
|
||||
(False, False),
|
||||
])
|
||||
def test_form_style_values_are_accepted(client, headers, sent, expected):
|
||||
"""The field arrives from a checkbox, which may send a string."""
|
||||
created = _create(client, headers, f'form-{sent}-{expected}',
|
||||
captureoutput=sent)
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['captureoutput'] is expected
|
||||
|
||||
|
||||
def test_a_nonsense_value_is_rejected(client, headers):
|
||||
"""Rejected rather than coerced: silently reading garbage as False would
|
||||
hide a broken client, and as True would leak output nobody agreed to."""
|
||||
created = _create(client, headers, 'bad-capture', captureoutput='maybe')
|
||||
|
||||
assert created.status_code == 400
|
||||
assert 'captureoutput' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_the_flag_can_be_changed_on_an_existing_script(client, headers):
|
||||
scriptid = _create(client, headers, 'toggle-me',
|
||||
captureoutput=True).get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'captureoutput': False})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['captureoutput'] is False
|
||||
|
||||
|
||||
def test_an_unrelated_update_leaves_the_flag_alone(client, headers):
|
||||
"""Editing a description must not quietly revert an author's decision."""
|
||||
scriptid = _create(client, headers, 'keep-my-flag',
|
||||
captureoutput=True).get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'scriptdescription': 'now documented'})
|
||||
|
||||
assert updated.get_json()['data']['captureoutput'] is True
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_carries_the_flag(app, client, db, headers):
|
||||
"""The agent cannot honour a decision it is never told about."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
|
||||
with app.app_context():
|
||||
from shopdb.core.models import Script
|
||||
|
||||
script = Script(scriptname='delivered', filename='script-1.ps1',
|
||||
sha256='a' * 64, runcontext='system',
|
||||
captureoutput=True)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, err = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert err is None
|
||||
assert descriptor['captureoutput'] is True
|
||||
250
tests/test_plugins/test_endpoints_agent_registration.py
Normal file
250
tests/test_plugins/test_endpoints_agent_registration.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Auto-registration of staged agent builds as a pushable Application.
|
||||
|
||||
The endpoint update_app push resolves an install source from the applications
|
||||
table alone; it cannot see the agent version store. Before this, a build could
|
||||
be staged and current while no application row existed at all, which is exactly
|
||||
what was found in the fleet - the agent was unpushable.
|
||||
|
||||
These tests pin the properties that make the bridge trustworthy:
|
||||
- staging creates the row, and re-staging updates it in place (never a row
|
||||
per build, because appname is unique and drift is the failure mode);
|
||||
- the packaged archive is byte-stable for unchanged input, because update_app
|
||||
pins a download to a digest and a drifting hash breaks verification;
|
||||
- every path that changes 'current' re-registers, including rollback;
|
||||
- registration failure never fails an upload that already landed on disk.
|
||||
|
||||
Uploads use PE-magic bytes with a monkeypatched version parser, matching the
|
||||
approach in test_endpoints_agentupload.py - building a real PE with an
|
||||
RT_VERSION resource inline is impractical.
|
||||
|
||||
Every test runs against an ISOLATED store. create_app('testing') resolves
|
||||
instance_path to the real repo instance/ directory, so without redirection
|
||||
these tests stage fake 130-byte binaries into the developer's actual agent
|
||||
store and leave invented versions behind in its manifest. The isolate fixture
|
||||
below is autouse for that reason: it is a correctness requirement, not a
|
||||
convenience.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.endpoints.api import agentstore, agentupload, agentregistry
|
||||
from shopdb.core.models.application import Application
|
||||
|
||||
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
||||
CURRENT_URL = '/api/endpoints/siteagent/binaries/{}/current'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 128
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_store(tmp_path, monkeypatch):
|
||||
"""Point the agent store and package output at a per-test directory.
|
||||
|
||||
Both must be redirected. Redirecting only the store would still write the
|
||||
~zip into the real applicationpackages tree under a live application id.
|
||||
"""
|
||||
siteagent = tmp_path / 'siteagent'
|
||||
packages = tmp_path / 'applicationpackages'
|
||||
siteagent.mkdir()
|
||||
packages.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
|
||||
monkeypatch.setattr(agentregistry, 'application_package_location',
|
||||
lambda: (str(packages), '/api/applications/package/'))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _upload(client, headers, monkeypatch, version, binkey='agent',
|
||||
payload=None, makecurrent=None):
|
||||
"""Stage a build through the real HTTP route."""
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: version)
|
||||
form = {'file': (io.BytesIO(payload or _PE_BYTES), 'shopdbagent.exe'),
|
||||
'version': version}
|
||||
if makecurrent is not None:
|
||||
form['makecurrent'] = makecurrent
|
||||
return client.post(UPLOAD_URL.format(binkey), data=form,
|
||||
content_type='multipart/form-data', headers=headers)
|
||||
|
||||
|
||||
def _agent_row():
|
||||
return Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).first()
|
||||
|
||||
|
||||
def test_staging_a_build_creates_a_pushable_application_row(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""Without this row, update_app has nothing to resolve and cannot push."""
|
||||
assert _agent_row() is None
|
||||
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
assert response.status_code == 200
|
||||
|
||||
registration = response.get_json()['data']['registration']
|
||||
assert registration['ok'] is True
|
||||
|
||||
row = _agent_row()
|
||||
assert row is not None
|
||||
# The row must be resolvable and pinned: a path update_app can fetch, and a
|
||||
# digest it can verify the bytes against.
|
||||
assert row.installpath == '/api/applications/package/{}/{}'.format(
|
||||
row.appid, agentregistry.AGENT_PACKAGE_FILENAME)
|
||||
assert row.installersha256 == registration['sha256']
|
||||
assert row.isinstallable is True
|
||||
# Hidden: this is machine-managed, and must not appear in the software
|
||||
# catalogue beside real licensed applications.
|
||||
assert row.ishidden is True
|
||||
|
||||
|
||||
def test_restaging_updates_the_same_row_rather_than_adding_one(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""appname is unique; a row per build would break the push target."""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
first = _agent_row()
|
||||
firstid = first.appid
|
||||
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.17.0.0')
|
||||
assert response.status_code == 200
|
||||
|
||||
assert Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).count() == 1
|
||||
row = _agent_row()
|
||||
assert row.appid == firstid
|
||||
# The row must follow the newly current build, not keep pointing at 1.16.
|
||||
assert row.installersha256 == \
|
||||
response.get_json()['data']['registration']['sha256']
|
||||
|
||||
|
||||
def test_packaging_the_same_build_twice_yields_the_same_digest(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""update_app pins bytes by digest.
|
||||
|
||||
zipfile stamps members with the current mtime by default, which made the
|
||||
archive hash change on every call for identical input. That would rewrite
|
||||
installersha256 out from under an endpoint mid-download and fail its
|
||||
SHA-256 verification.
|
||||
"""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
|
||||
firstpayload, firstmanifest = agentstore.build_current_package()
|
||||
secondpayload, secondmanifest = agentstore.build_current_package()
|
||||
|
||||
assert firstmanifest['sha256'] == secondmanifest['sha256']
|
||||
assert firstpayload == secondpayload
|
||||
|
||||
|
||||
def test_the_package_contains_the_staged_binary_under_a_fixed_name(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""The install script addresses members by name, not by discovery."""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
|
||||
payload, manifest = agentstore.build_current_package()
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
assert archive.testzip() is None
|
||||
names = archive.namelist()
|
||||
assert agentstore.PACKAGE_MEMBER_NAMES['agent'] in names
|
||||
assert manifest['agentversion'] == '1.16.0.0'
|
||||
assert manifest['contents']['agent']['version'] == '1.16.0.0'
|
||||
|
||||
|
||||
def test_the_written_package_file_matches_the_recorded_digest(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""A row whose digest disagrees with the file on disk is unpushable."""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
row = _agent_row()
|
||||
|
||||
path = agentregistry._package_path(row.appid)
|
||||
assert os.path.isfile(path)
|
||||
with open(path, 'rb') as handle:
|
||||
ondisk = hashlib.sha256(handle.read()).hexdigest()
|
||||
assert ondisk == row.installersha256
|
||||
# The atomic-write temp file must not survive a successful write.
|
||||
assert not os.path.isfile(path + '.tmp')
|
||||
|
||||
|
||||
def test_rolling_back_repoints_the_row_at_the_restored_build(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""A rollback changes the bytes the fleet should get.
|
||||
|
||||
If only upload re-registered, the row would still pin the build the
|
||||
operator just rolled away from, and the push would ship it anyway.
|
||||
"""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0',
|
||||
payload=b'MZ' + b'\x01' * 256)
|
||||
oldsha = _agent_row().installersha256
|
||||
|
||||
_upload(client, auth_headers, monkeypatch, '1.17.0.0',
|
||||
payload=b'MZ' + b'\x02' * 256)
|
||||
newsha = _agent_row().installersha256
|
||||
assert newsha != oldsha
|
||||
|
||||
response = client.put(CURRENT_URL.format('agent'),
|
||||
json={'version': '1.16.0.0'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['registration']['ok'] is True
|
||||
|
||||
assert agentstore.current_version('agent') == '1.16.0.0'
|
||||
# Back to the rolled-to build's package, not the newer one.
|
||||
assert _agent_row().installersha256 == oldsha
|
||||
|
||||
|
||||
def test_a_registration_failure_does_not_fail_the_upload(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""The binary is already staged and audited by the time this runs.
|
||||
|
||||
Turning a packaging problem into an error response would tell the operator
|
||||
the upload was rejected when it actually succeeded, inviting a re-upload of
|
||||
a build that is already there.
|
||||
"""
|
||||
def _explode():
|
||||
raise OSError('simulated packaging failure')
|
||||
|
||||
monkeypatch.setattr(agentregistry.agentstore, 'build_current_package',
|
||||
_explode)
|
||||
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()['data']
|
||||
# The staging itself still stands...
|
||||
assert body['current'] == '1.16.0.0'
|
||||
# ...and the failure is surfaced rather than swallowed.
|
||||
assert body['registration']['ok'] is False
|
||||
assert 'simulated packaging failure' in body['registration']['error']
|
||||
|
||||
|
||||
def test_staging_only_a_tray_reports_that_there_is_nothing_to_package(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""The bundle is defined by the agent; a tray alone is not pushable."""
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.6.5.0',
|
||||
binkey='tray')
|
||||
|
||||
assert response.status_code == 200
|
||||
registration = response.get_json()['data']['registration']
|
||||
assert registration['ok'] is False
|
||||
assert 'no current agent build' in registration['error']
|
||||
assert _agent_row() is None
|
||||
|
||||
|
||||
def test_these_tests_do_not_touch_the_real_agent_store(
|
||||
client, db, app, auth_headers, monkeypatch, isolate_store):
|
||||
"""Guard the isolation itself.
|
||||
|
||||
An earlier draft of this file wrote fake 130-byte binaries into the repo's
|
||||
real instance/siteagent/ tree and invented a version in its manifest,
|
||||
because create_app('testing') shares the developer's instance_path. If that
|
||||
redirection ever regresses, fail here rather than silently corrupting a
|
||||
staged build again.
|
||||
"""
|
||||
_upload(client, auth_headers, monkeypatch, '9.9.9.9')
|
||||
|
||||
# The staged build landed inside the per-test directory...
|
||||
staged = isolate_store / 'siteagent' / 'agent' / '9.9.9.9'
|
||||
assert staged.is_dir()
|
||||
|
||||
# ...and the real store never saw it.
|
||||
realstore = os.path.join(app.instance_path, 'siteagent', 'agent', '9.9.9.9')
|
||||
assert not os.path.exists(realstore)
|
||||
281
tests/test_plugins/test_endpoints_agent_upgrade_route.py
Normal file
281
tests/test_plugins/test_endpoints_agent_upgrade_route.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""The agent self-upgrade enqueue route.
|
||||
|
||||
Replacing the agent is self-modifying the control plane: the process that
|
||||
receives the command is the one being swapped. The route therefore sits behind
|
||||
endpoints.agentupdate, and - more importantly - it does NOT implement its own
|
||||
enqueue. It prepares the delivery and delegates to enqueue_endpoint_command so
|
||||
the endpoint lock, the Configuration Policy gate, stale-command reaping and the
|
||||
at-most-one-in-flight rule all still apply.
|
||||
|
||||
These tests pin that delegation. A parallel enqueue path that quietly skipped a
|
||||
lock or the in-flight rule would be invisible in normal use and would show up
|
||||
as a fleet-wide agent swap landing on a quarantined machine.
|
||||
|
||||
The store is isolated per test: create_app('testing') resolves instance_path to
|
||||
the real repo instance/, so without redirection these would stage binaries into
|
||||
the developer's actual agent store.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from plugins.endpoints.api import agentstore, agentregistry, agentupgradescript
|
||||
|
||||
UPGRADE_URL = '/api/endpoints/{}/agentupgrade'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 4096
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_store(tmp_path, monkeypatch):
|
||||
"""Redirect the agent store, package output and script bodies."""
|
||||
siteagent = tmp_path / 'siteagent'
|
||||
packages = tmp_path / 'applicationpackages'
|
||||
scripts = tmp_path / 'scripts'
|
||||
for directory in (siteagent, packages, scripts):
|
||||
directory.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
|
||||
monkeypatch.setattr(agentregistry, 'application_package_location',
|
||||
lambda: (str(packages), '/api/applications/package/'))
|
||||
monkeypatch.setattr(agentupgradescript, 'script_body_location',
|
||||
lambda: (str(scripts), '/api/scripts/body/'))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _publish_agent(version='1.16.0.0'):
|
||||
"""Stage a build so there is something to upgrade to."""
|
||||
agentstore.store_version('agent', version, _PE_BYTES, make_current=True)
|
||||
|
||||
|
||||
def _endpoint(db, hostname, agentversion='1.15.0.0'):
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
|
||||
agentversion=agentversion))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _login(db, client, username, permissions=None, admin=False):
|
||||
"""A user holding exactly the named permissions (or the admin role)."""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
|
||||
rolename = 'admin' if admin else username + 'role'
|
||||
role = Role.query.filter_by(rolename=rolename).first()
|
||||
if role is None:
|
||||
role = Role(rolename=rolename)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
if not admin:
|
||||
for name in (permissions or []):
|
||||
perm = Permission.query.filter_by(name=name).first()
|
||||
if perm is not None and perm not in role.permissions:
|
||||
role.permissions.append(perm)
|
||||
db.session.commit()
|
||||
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def test_an_upgrade_queues_as_a_pinned_run_script(client, db, app):
|
||||
"""The push must ride the normal script channel, digest-pinned.
|
||||
|
||||
Landing as run_script is what makes the body verifiable on the endpoint;
|
||||
a bespoke action would need its own pinning and would not get it.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE01')
|
||||
headers = _login(db, client, 'upgradeadmin', admin=True)
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
command = EndpointCommand.query.filter_by(assetid=assetid).first()
|
||||
assert command is not None
|
||||
assert command.action == 'run_script'
|
||||
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
assert command.target == 'script:{}'.format(seeded['scriptid'])
|
||||
# Pinned to the exact body approved at enqueue, so a later edit cannot
|
||||
# swap what the endpoint runs.
|
||||
assert command.pinnedsha256 == seeded['sha256']
|
||||
|
||||
|
||||
def test_the_upgrade_requires_the_agentupdate_permission(client, db, app):
|
||||
"""endpoints.command alone must not be able to swap the SYSTEM agent."""
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE02')
|
||||
headers = _login(db, client, 'plainoperator',
|
||||
permissions=['endpoints.view', 'endpoints.command'])
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_an_upgrade_is_refused_when_no_build_is_published(client, db, app):
|
||||
"""Nothing staged means there is no payload; refuse before queueing.
|
||||
|
||||
Queueing anyway would send an endpoint to fetch a package that does not
|
||||
exist and fail opaquely minutes later.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'UPGRADE03')
|
||||
headers = _login(db, client, 'nobuildadmin', admin=True)
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
assert response.status_code == 400
|
||||
assert 'No agent build is published' in \
|
||||
response.get_json()['data']['error']['message']
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_an_endpoint_lock_still_blocks_the_upgrade(client, db, app):
|
||||
"""The delegate's lock check must not be bypassed by this route.
|
||||
|
||||
A quarantined machine is reserved by a person; a fleet agent swap is
|
||||
exactly the kind of action that must respect that.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE04')
|
||||
|
||||
# Lock as one user, then act as a different non-admin holder of the grant.
|
||||
owner = _login(db, client, 'lockowner', admin=True)
|
||||
locked = client.post(f'/api/endpoints/{assetid}/lock',
|
||||
json={'reason': 'bench repair'}, headers=owner)
|
||||
assert locked.status_code in (200, 201)
|
||||
|
||||
headers = _login(db, client, 'upgradeoperator',
|
||||
permissions=['endpoints.view', 'endpoints.command',
|
||||
'endpoints.agentupdate',
|
||||
'endpoints.runscript'])
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert 'locked' in \
|
||||
response.get_json()['data']['error']['message'].lower()
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_a_second_upgrade_cannot_stack_on_an_in_flight_command(client, db, app):
|
||||
"""at-most-one-in-flight must still hold through this route."""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE05')
|
||||
headers = _login(db, client, 'stackadmin', admin=True)
|
||||
|
||||
first = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert second.status_code == 409
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 1
|
||||
|
||||
|
||||
def test_the_upgrade_refreshes_the_package_before_queueing(client, db, app):
|
||||
"""A newer staged build must be what gets pushed.
|
||||
|
||||
If the route queued against a previously registered row, an operator who
|
||||
uploaded a new agent and immediately pushed would ship the old one.
|
||||
"""
|
||||
from shopdb.api import Application
|
||||
|
||||
_publish_agent('1.16.0.0')
|
||||
assetid = _endpoint(db, 'UPGRADE06')
|
||||
headers = _login(db, client, 'refreshadmin', admin=True)
|
||||
|
||||
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
row = Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).first()
|
||||
firstsha = row.installersha256
|
||||
|
||||
# Stage a different build and push to a second endpoint.
|
||||
agentstore.store_version('agent', '1.17.0.0', b'MZ' + b'\x01' * 4096,
|
||||
make_current=True)
|
||||
second = _endpoint(db, 'UPGRADE07')
|
||||
response = client.post(UPGRADE_URL.format(second), json={}, headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
db.session.expire_all()
|
||||
row = Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).first()
|
||||
assert row.installersha256 != firstsha
|
||||
|
||||
|
||||
def test_a_failed_push_is_not_audited_as_an_upgrade(client, db, app):
|
||||
"""A refused push must not leave an AgentUpgrade event behind."""
|
||||
from shopdb.api import AuditLog
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE08')
|
||||
headers = _login(db, client, 'auditadmin', admin=True)
|
||||
|
||||
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
before = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
|
||||
|
||||
# Second push is refused by the in-flight rule.
|
||||
blocked = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert blocked.status_code == 409
|
||||
|
||||
after = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_the_seeded_script_body_is_written_and_pinned(client, db, app,
|
||||
isolate_store):
|
||||
"""The agent verifies the body by digest, so both must exist and agree."""
|
||||
import hashlib
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE09')
|
||||
headers = _login(db, client, 'bodyadmin', admin=True)
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
path = os.path.join(str(isolate_store / 'scripts'),
|
||||
'script-{}.ps1'.format(seeded['scriptid']))
|
||||
assert os.path.isfile(path)
|
||||
with open(path, 'rb') as handle:
|
||||
body = handle.read()
|
||||
assert hashlib.sha256(body).hexdigest() == seeded['sha256']
|
||||
# The delivery path must be fully resolved, not a literal placeholder.
|
||||
assert b'{{AGENT_APPID}}' not in body
|
||||
158
tests/test_plugins/test_endpoints_agent_version_guard.py
Normal file
158
tests/test_plugins/test_endpoints_agent_version_guard.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""The agent version guard on command enqueue.
|
||||
|
||||
An agent older than an action has no case for it and falls through to its
|
||||
unknown-action default, answering "Unknown or disallowed action". That reads as
|
||||
an authorization refusal, so the operator goes looking at permissions they
|
||||
already hold - which is exactly what happened on a live endpoint running 1.15.0
|
||||
after collect_log shipped in 1.16.0.
|
||||
|
||||
These tests pin the refusal at the SERVER, before the command is ever queued,
|
||||
and pin that the message names both versions so the answer is actionable.
|
||||
"""
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
def _endpoint(db, hostname, agentversion):
|
||||
"""An Asset + Computer pair whose reported state carries an agent version."""
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
|
||||
agentversion=agentversion))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _admin(db, client, username='guardadmin'):
|
||||
"""An admin, so permission and policy gates cannot be what refuses."""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
role = Role.query.filter_by(rolename='admin').first()
|
||||
if role is None:
|
||||
role = Role(rolename='admin')
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _collect(client, headers, assetid):
|
||||
return client.post(f'/api/endpoints/{assetid}/commands',
|
||||
json={'action': 'collect_log',
|
||||
'target': r'C:\logs\app.log'},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_an_agent_below_the_minimum_is_refused_at_enqueue(app, client, db):
|
||||
"""The command must not be queued at all.
|
||||
|
||||
Queueing it would spend the endpoint's one in-flight command slot and leave
|
||||
a failed row that reads as endpoint misbehaviour, when the truth is the
|
||||
agent simply predates the feature.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'oldagentbox', '1.15.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()['data']['error']['message']
|
||||
# Both versions, or the operator cannot tell what to do about it.
|
||||
assert '1.16.0' in message
|
||||
assert '1.15.0.0' in message
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_an_agent_at_the_minimum_is_allowed(app, client, db):
|
||||
"""The guard must not refuse a capable endpoint.
|
||||
|
||||
1.16.0.0 against a 1.16.0 floor is the four-vs-three-part spelling of the
|
||||
same build; a lexical compare would get this wrong.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'newagentbox', '1.16.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code in (200, 201)
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid,
|
||||
action='collect_log').count() == 1
|
||||
|
||||
|
||||
def test_a_lexically_larger_but_older_version_is_still_refused(app, client, db):
|
||||
"""'1.9.0.0' must not pass a '1.16.0' floor.
|
||||
|
||||
String comparison says '9' > '1' and lets it through. This is the specific
|
||||
bug the numeric parse exists to prevent, so it gets its own test rather
|
||||
than living only in the helper's unit coverage.
|
||||
"""
|
||||
assetid = _endpoint(db, 'lexicaltrapbox', '1.9.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert '1.16.0' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_unknown_agent_version_is_refused_rather_than_guessed(app, client, db):
|
||||
"""A placeholder version fails closed.
|
||||
|
||||
Seeded rows carry '0.0.0-sample'. There is no honest ordering between a
|
||||
placeholder and a real build, and letting it through only moves the failure
|
||||
to the agent where the error message is the misleading one.
|
||||
"""
|
||||
assetid = _endpoint(db, 'samplebox', '0.0.0-sample')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_an_action_without_a_floor_is_unaffected(app, client, db):
|
||||
"""The guard is per-action, not global.
|
||||
|
||||
collect_now declares no minimum, so an old agent must still be able to run
|
||||
it - otherwise adding a floor to one action quietly narrows the others.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'refreshbox', '1.11.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/commands',
|
||||
json={'action': 'collect_now'}, headers=headers)
|
||||
|
||||
assert response.status_code in (200, 201)
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid,
|
||||
action='collect_now').count() == 1
|
||||
@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
|
||||
from plugins.endpoints.commands import (
|
||||
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
|
||||
requires_target,
|
||||
requires_target, minimum_agent_version_for, agent_meets_minimum,
|
||||
parse_agent_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -196,6 +197,81 @@ def test_collect_now_survives_a_policy_that_never_mentions_it():
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_collect_log_survives_a_policy_that_never_mentions_it():
|
||||
"""Log collection must not be narrowed away by an unrelated policy.
|
||||
|
||||
Same intersection trap as collect_now, but with a sharper failure: every
|
||||
existing policy predates this action and so cannot name it, meaning any
|
||||
endpoint carrying a policy refused log collection outright - including for
|
||||
an admin, who reads "requires permission" and goes looking at a grant they
|
||||
already hold. Logs are also what an operator reaches for when an endpoint
|
||||
is already misbehaving, so a policy gate here withholds the evidence
|
||||
exactly when it is wanted.
|
||||
"""
|
||||
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
|
||||
|
||||
assert 'collect_log' in POLICY_EXEMPT_ACTIONS
|
||||
# Exemption from POLICY is not exemption from PERMISSION. Unlike
|
||||
# collect_now this one DOES carry an elevated grant, so the assertion is
|
||||
# that the grant survived the exemption rather than being loosened by it.
|
||||
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
|
||||
|
||||
|
||||
def test_collect_log_declares_the_agent_version_that_implements_it():
|
||||
"""collect_log names its minimum agent version.
|
||||
|
||||
An agent older than the action has no case for it and falls through to its
|
||||
unknown-action default, answering "Unknown or disallowed action" - which
|
||||
reads as an authorization refusal and sends the operator to check a grant
|
||||
they already hold. The floor is what lets the server refuse first with the
|
||||
actionable answer instead.
|
||||
"""
|
||||
assert minimum_agent_version_for('collect_log') == '1.16.0'
|
||||
# Actions that every shipped agent understands must NOT declare a floor,
|
||||
# or the guard starts refusing endpoints that are perfectly capable.
|
||||
assert minimum_agent_version_for('collect_now') is None
|
||||
assert minimum_agent_version_for('system_restart') is None
|
||||
|
||||
|
||||
def test_agent_version_comparison_survives_real_fleet_version_strings():
|
||||
"""Version comparison is numeric per part, not lexical.
|
||||
|
||||
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
|
||||
while the csproj and binary store carry three ('1.16.0'), so the same build
|
||||
must compare equal across both spellings. And a lexical compare ranks
|
||||
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
|
||||
old agent past the guard, the exact failure the guard exists to prevent.
|
||||
"""
|
||||
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
|
||||
assert agent_meets_minimum('1.16.0', '1.16.0')
|
||||
assert agent_meets_minimum('1.16.0.0', '1.16.0')
|
||||
assert agent_meets_minimum('1.17.0.0', '1.16.0')
|
||||
assert agent_meets_minimum('2.0.0.0', '1.16.0')
|
||||
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
|
||||
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
|
||||
# The lexical trap.
|
||||
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
|
||||
|
||||
|
||||
def test_an_unreadable_agent_version_fails_the_guard_closed():
|
||||
"""An unparseable or absent version does not satisfy a minimum.
|
||||
|
||||
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
|
||||
endpoint reports nothing at all. There is no honest ordering between a
|
||||
placeholder and a real build, so the guard refuses rather than guessing.
|
||||
Letting it through would only move the failure to the agent, where the
|
||||
error message is the misleading one.
|
||||
"""
|
||||
assert parse_agent_version('0.0.0-sample') is None
|
||||
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
|
||||
assert not agent_meets_minimum(None, '1.16.0')
|
||||
assert not agent_meets_minimum('', '1.16.0')
|
||||
# No declared floor means no opinion: an action every agent implements must
|
||||
# stay available even to an endpoint whose version we cannot read.
|
||||
assert agent_meets_minimum('0.0.0-sample', None)
|
||||
assert agent_meets_minimum(None, None)
|
||||
|
||||
|
||||
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
"""The two software-delivery actions are allowlisted with a targetkind so
|
||||
the enqueue route accepts them and carries their subject on the wire:
|
||||
|
||||
200
tests/test_plugins/test_endpoints_script_runs.py
Normal file
200
tests/test_plugins/test_endpoints_script_runs.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""The Run Scripts tab's history feed.
|
||||
|
||||
A successful script produces a green badge and a one-line message; nothing in
|
||||
that tells an operator whether the script did any work. These tests pin the
|
||||
contract the tab relies on: only script runs, newest first, capped, and
|
||||
carrying the agent's structured detail so the exit code and run context are
|
||||
visible without opening the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
def _endpoint(db, hostname='scriptbox'):
|
||||
"""An Asset + Computer pair with a reported state, as the routes expect."""
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _viewer(db, client, username='scriptviewer'):
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
role = Role.query.filter_by(rolename='scriptviewerrole').first()
|
||||
if role is None:
|
||||
role = Role(rolename='scriptviewerrole')
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
role.permissions = Permission.query.filter(
|
||||
Permission.name.in_(['endpoints.view'])).all()
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _script(db, name='test-open-notepad'):
|
||||
from shopdb.core.models import Script
|
||||
|
||||
script = Script(scriptname=name, runcontext='system')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
return script.scriptid
|
||||
|
||||
|
||||
def _command(db, assetid, target, createdat, action='run_script',
|
||||
status='succeeded', resultjson=None, resultmessage=None):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
command = EndpointCommand(assetid=assetid, action=action, target=target,
|
||||
status=status, resultmessage=resultmessage,
|
||||
resultjson=resultjson, createdat=createdat)
|
||||
db.session.add(command)
|
||||
db.session.commit()
|
||||
return command.endpointcommandid
|
||||
|
||||
|
||||
def test_only_script_runs_are_returned(app, client, db):
|
||||
"""A restart sitting in the same feed is not a script run.
|
||||
|
||||
The tab asks a narrower question than the activity panel, so filtering has
|
||||
to happen server-side; a mixed feed sliced client-side would quietly show
|
||||
fewer than five script runs whenever other commands interleave.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
|
||||
_command(db, assetid, 'restart', datetime(2026, 1, 1, 11, 0),
|
||||
action='restart')
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
rows = response.get_json()['data']
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [row['action'] for row in rows] == ['run_script']
|
||||
|
||||
|
||||
def test_newest_first_and_capped_at_five(app, client, db):
|
||||
"""Six runs, five rows, most recent at the top."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
for hour in range(6):
|
||||
_command(db, assetid, f'script:{scriptid}',
|
||||
datetime(2026, 1, 1, 10 + hour, 0))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
rows = response.get_json()['data']
|
||||
|
||||
assert len(rows) == 5
|
||||
stamps = [row['createdat'] for row in rows]
|
||||
assert stamps == sorted(stamps, reverse=True)
|
||||
|
||||
|
||||
def test_agent_detail_is_exposed(app, client, db):
|
||||
"""The exit code and run context are the evidence behind a green badge."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
|
||||
resultmessage='Script completed (system context)',
|
||||
resultjson=json.dumps({'kind': 'script', 'exitcode': 0,
|
||||
'runcontext': 'system'}))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
row = response.get_json()['data'][0]
|
||||
|
||||
assert row['resultdetail']['exitcode'] == 0
|
||||
assert row['resultdetail']['runcontext'] == 'system'
|
||||
|
||||
|
||||
def test_the_script_name_is_resolved(app, client, db):
|
||||
"""'script:5' is the wire target, not what the operator picked."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db, name='rebuild-shopfloor-user')
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
row = response.get_json()['data'][0]
|
||||
|
||||
assert row['scriptname'] == 'rebuild-shopfloor-user'
|
||||
|
||||
|
||||
def test_malformed_detail_does_not_break_the_list(app, client, db):
|
||||
"""resultjson is agent-supplied text and can arrive truncated.
|
||||
|
||||
A diagnostics panel failing to parse must degrade to "no detail", not take
|
||||
down the history that surrounds it.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
|
||||
resultjson='{"kind": "script", "exitcode":')
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data'][0]['resultdetail'] is None
|
||||
|
||||
|
||||
def test_view_permission_is_required(app, client, db):
|
||||
"""History reveals what has been run on a machine, so it is gated."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns')
|
||||
|
||||
assert response.status_code in (401, 403)
|
||||
Reference in New Issue
Block a user