Make rollback, first upgrades and pin changes work as tested on Windows
Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
This commit is contained in:
@@ -41,8 +41,11 @@ public sealed class AgentHttpFactory
|
|||||||
// Caller owns and disposes the client (it disposes the handler chain).
|
// Caller owns and disposes the client (it disposes the handler chain).
|
||||||
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
|
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
|
||||||
{
|
{
|
||||||
|
// Lifetime cap: pooled TLS sessions were validated against the pins of
|
||||||
|
// their handshake, so recycle them for a pin change to reach new ones.
|
||||||
var sockets = new SocketsHttpHandler
|
var sockets = new SocketsHttpHandler
|
||||||
{
|
{
|
||||||
|
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||||
SslOptions = new SslClientAuthenticationOptions
|
SslOptions = new SslClientAuthenticationOptions
|
||||||
{
|
{
|
||||||
RemoteCertificateValidationCallback = (_, cert, _, errors)
|
RemoteCertificateValidationCallback = (_, cert, _, errors)
|
||||||
@@ -57,11 +60,9 @@ public sealed class AgentHttpFactory
|
|||||||
// server keep-alive, or a reused dead socket yields "unexpected
|
// server keep-alive, or a reused dead socket yields "unexpected
|
||||||
// EOF or 0 bytes from the transport stream" (1.6.2).
|
// EOF or 0 bytes from the transport stream" (1.6.2).
|
||||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
|
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
|
||||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
|
||||||
break;
|
break;
|
||||||
case AgentHttpChannel.FleetAlert:
|
case AgentHttpChannel.FleetAlert:
|
||||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
|
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
|
||||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,40 +177,94 @@ public sealed record AgentServerError(string Code, string Message)
|
|||||||
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
|
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
|
||||||
// configured only a matching leaf is accepted and chain/name errors are ignored
|
// configured only a matching leaf is accepted and chain/name errors are ignored
|
||||||
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None.
|
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None.
|
||||||
|
// Registry pins re-read at most every RefreshInterval on the handshake path, so a
|
||||||
|
// GE-Enforce fix or a "next" rotation pin lands without a service restart.
|
||||||
public sealed class ServerPinSet
|
public sealed class ServerPinSet
|
||||||
{
|
{
|
||||||
public const string RegistryValue = "ServerPins";
|
public const string RegistryValue = "ServerPins";
|
||||||
|
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
private readonly HashSet<string> spkiPins;
|
private sealed record Snapshot(HashSet<string> SpkiPins, string RawRegistry)
|
||||||
private readonly string? legacyThumbprint;
|
|
||||||
private readonly ILogger logger;
|
|
||||||
|
|
||||||
private ServerPinSet(HashSet<string> spkiPins, string? legacyThumbprint, ILogger logger)
|
|
||||||
{
|
{
|
||||||
this.spkiPins = spkiPins;
|
public string Signature => string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal));
|
||||||
this.legacyThumbprint = legacyThumbprint;
|
|
||||||
this.logger = logger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsPinned => spkiPins.Count > 0 || legacyThumbprint is not null;
|
private readonly HashSet<string> configPins;
|
||||||
|
private readonly string? legacyThumbprint;
|
||||||
|
private readonly ILogger logger;
|
||||||
|
private readonly Func<bool, List<string>?> readRegistry;
|
||||||
|
private readonly object refreshLock = new();
|
||||||
|
private volatile Snapshot current;
|
||||||
|
private long nextRefreshTicks;
|
||||||
|
|
||||||
|
private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger,
|
||||||
|
Func<bool, List<string>?> readRegistry)
|
||||||
|
{
|
||||||
|
this.configPins = configPins;
|
||||||
|
this.legacyThumbprint = legacyThumbprint;
|
||||||
|
this.logger = logger;
|
||||||
|
this.readRegistry = readRegistry;
|
||||||
|
current = Build(readRegistry(false) ?? new List<string>());
|
||||||
|
nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPinned => Current().SpkiPins.Count > 0 || legacyThumbprint is not null;
|
||||||
|
|
||||||
public static ServerPinSet Load(AgentConfig config, ILogger logger)
|
public static ServerPinSet Load(AgentConfig config, ILogger logger)
|
||||||
{
|
{
|
||||||
var pins = new HashSet<string>(StringComparer.Ordinal);
|
var pins = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var pin in config.ServerPins ?? new List<string>())
|
foreach (var pin in config.ServerPins ?? new List<string>())
|
||||||
AddPin(pins, pin, "appsettings.json", logger);
|
AddPin(pins, pin, "appsettings.json", logger);
|
||||||
foreach (var pin in ReadRegistryPins(logger))
|
|
||||||
AddPin(pins, pin, "registry", logger);
|
|
||||||
|
|
||||||
string? thumbprint = null;
|
string? thumbprint = null;
|
||||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||||
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
|
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
|
||||||
|
var count = set.current.SpkiPins.Count;
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||||
pins.Count, thumbprint is null ? "" : " + legacy thumbprint",
|
count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||||
pins.Count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||||
return new ServerPinSet(pins, thumbprint, logger);
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap tick compare per handshake; registry read only when the window lapsed.
|
||||||
|
private Snapshot Current()
|
||||||
|
{
|
||||||
|
if (Environment.TickCount64 < Interlocked.Read(ref nextRefreshTicks))
|
||||||
|
return current;
|
||||||
|
lock (refreshLock)
|
||||||
|
{
|
||||||
|
if (Environment.TickCount64 < nextRefreshTicks)
|
||||||
|
return current;
|
||||||
|
var raw = readRegistry(true);
|
||||||
|
Interlocked.Exchange(ref nextRefreshTicks,
|
||||||
|
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
|
||||||
|
var previous = current;
|
||||||
|
if (raw is null || string.Join("|", raw) == previous.RawRegistry)
|
||||||
|
return previous;
|
||||||
|
|
||||||
|
var next = Build(raw);
|
||||||
|
current = next;
|
||||||
|
if (next.Signature != previous.Signature)
|
||||||
|
logger.LogInformation(
|
||||||
|
"Server pins changed: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
|
||||||
|
next.SpkiPins.Count,
|
||||||
|
string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])),
|
||||||
|
legacyThumbprint is null ? "" : " + legacy thumbprint",
|
||||||
|
next.SpkiPins.Count > 0 || legacyThumbprint is not null ? "pinned" : "Windows chain validation");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed-pin warnings only fire when the raw registry text changes.
|
||||||
|
private Snapshot Build(List<string> raw)
|
||||||
|
{
|
||||||
|
var pins = new HashSet<string>(configPins, StringComparer.Ordinal);
|
||||||
|
foreach (var pin in raw)
|
||||||
|
AddPin(pins, pin, "registry", logger);
|
||||||
|
return new Snapshot(pins, string.Join("|", raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
|
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
|
||||||
@@ -220,7 +275,8 @@ public sealed class ServerPinSet
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsPinned)
|
var snapshot = Current();
|
||||||
|
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null)
|
||||||
{
|
{
|
||||||
if (errors == SslPolicyErrors.None)
|
if (errors == SslPolicyErrors.None)
|
||||||
return true;
|
return true;
|
||||||
@@ -235,7 +291,7 @@ public sealed class ServerPinSet
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var spki = SpkiSha256(leaf);
|
var spki = SpkiSha256(leaf);
|
||||||
if (spkiPins.Contains(spki))
|
if (snapshot.SpkiPins.Contains(spki))
|
||||||
return true;
|
return true;
|
||||||
if (legacyThumbprint is not null
|
if (legacyThumbprint is not null
|
||||||
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -270,7 +326,9 @@ public sealed class ServerPinSet
|
|||||||
pins.Add(pin);
|
pins.Add(pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> ReadRegistryPins(ILogger logger)
|
// Startup keeps whatever views read. A refresh returns null on any failed view
|
||||||
|
// so a transient denial cannot silently drop pins (and the server with them).
|
||||||
|
private static List<string>? ReadRegistryPins(ILogger logger, bool refresh)
|
||||||
{
|
{
|
||||||
var found = new List<string>();
|
var found = new List<string>();
|
||||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||||
@@ -291,6 +349,11 @@ public sealed class ServerPinSet
|
|||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
if (refresh)
|
||||||
|
{
|
||||||
|
logger.LogDebug(error, "Registry re-read of {Value} in {View} view failed; keeping pins", RegistryValue, view);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
|
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ public static class ShopDbServerPinValidator
|
|||||||
return Pins.Contains(LastPresented);
|
return Pins.Contains(LastPresented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsPinned(string pin)
|
||||||
|
{
|
||||||
|
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
|
||||||
|
}
|
||||||
|
|
||||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||||
public static string SpkiSha256Hex(byte[] der)
|
public static string SpkiSha256Hex(byte[] der)
|
||||||
@@ -271,8 +276,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
|
|||||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||||
}
|
}
|
||||||
$presented = ''
|
$presented = ''
|
||||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
$matched = $false
|
||||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
if ('ShopDbServerPinValidator' -as [type]) {
|
||||||
|
$presented = [string][ShopDbServerPinValidator]::LastPresented
|
||||||
|
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
|
||||||
|
}
|
||||||
|
# Innermost exception text: the outer WebException hides the real cause
|
||||||
|
# (unexpected EOF, connection reset, trust failure).
|
||||||
|
$message = ''
|
||||||
|
$exception = $ErrorRecord.Exception
|
||||||
|
while ($exception) {
|
||||||
|
if ($exception.Message) { $message = $exception.Message }
|
||||||
|
$exception = $exception.InnerException
|
||||||
|
}
|
||||||
|
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-ShopDbAuthFailure($Failure) {
|
function Write-ShopDbAuthFailure($Failure) {
|
||||||
@@ -292,10 +309,17 @@ function Write-ShopDbAuthFailure($Failure) {
|
|||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||||
|
# A pin was seen, but only an absent pin is a pin failure. A
|
||||||
|
# matched pin means the handshake passed and something later
|
||||||
|
# failed (server dropped mid-transfer, timeout).
|
||||||
|
if ($Failure.PinMatched) {
|
||||||
|
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
|
||||||
|
} else {
|
||||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
||||||
|
|||||||
@@ -626,8 +626,10 @@ key authenticated the call, the hostname matches exactly one computer, the
|
|||||||
serial on record matches (trimmed, case-insensitive) and the asset has no active
|
serial on record matches (trimmed, case-insensitive) and the asset has no active
|
||||||
credential. Otherwise the request is pending with one of the reasons `unknown
|
credential. Otherwise the request is pending with one of the reasons `unknown
|
||||||
hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`,
|
hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`,
|
||||||
`already enrolled`, `awaiting approval`, `nonce changed`. The credential is in
|
`already enrolled`, `awaiting approval`, `nonce changed`, `reenroll blocked`.
|
||||||
the 201 body exactly once.
|
`reenroll blocked` means an admin revoked the PC with `blockreenroll` (9.7); the
|
||||||
|
agent keeps retrying on its normal schedule and enrolls once an admin calls
|
||||||
|
`allow-reenroll`. The credential is in the 201 body exactly once.
|
||||||
|
|
||||||
### 9.3 Error codes
|
### 9.3 Error codes
|
||||||
|
|
||||||
@@ -646,7 +648,9 @@ as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
|
|||||||
`POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`,
|
`POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`,
|
||||||
`POST /api/endpoints/alerts/<id>/acknowledge`. With a credential, a missing
|
`POST /api/endpoints/alerts/<id>/acknowledge`. With a credential, a missing
|
||||||
hostname is allowed and the asset comes from the credential; an acknowledge with
|
hostname is allowed and the asset comes from the credential; an acknowledge with
|
||||||
assetid 0 or absent means the caller. The core collector
|
assetid 0 or absent means the caller. Acknowledging an alert that does not
|
||||||
|
target the caller returns 404 `NOT_FOUND`, as for an unknown id.
|
||||||
|
The core collector
|
||||||
(`/api/collector/computers`, `/api/collector/printers`) accepts the same header
|
(`/api/collector/computers`, `/api/collector/printers`) accepts the same header
|
||||||
for the SYSTEM reporter scripts.
|
for the SYSTEM reporter scripts.
|
||||||
|
|
||||||
@@ -673,6 +677,14 @@ for the SYSTEM reporter scripts.
|
|||||||
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
|
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
|
||||||
`client/Get-ShopDBServerPin.ps1` computes it from a URL or a file;
|
`client/Get-ShopDBServerPin.ps1` computes it from a URL or a file;
|
||||||
`client/Set-ShopDBServerPins.ps1` writes it.
|
`client/Set-ShopDBServerPins.ps1` writes it.
|
||||||
|
- The agent re-reads the pin set within about 60 seconds of a change; no
|
||||||
|
service restart is needed.
|
||||||
|
- The same `GET /api/endpoints/settings/server-cert` carries `sha1thumbprint`
|
||||||
|
(hex SHA-1 of the certificate), and
|
||||||
|
`GET /api/endpoints/settings/server-cert/download` returns the public
|
||||||
|
certificate as a `.cer`. `client/Install-ShopDBServerCertificate.ps1` checks
|
||||||
|
the file against that thumbprint and adds it to `LocalMachine\Root` on PCs
|
||||||
|
whose pre-1.20 agent validates the chain when it downloads the upgrade.
|
||||||
- Any pin configured: accept the handshake if and only if the leaf key hash is
|
- Any pin configured: accept the handshake if and only if the leaf key hash is
|
||||||
in the set (chain and name errors ignored). No pin: require a clean Windows
|
in the set (chain and name errors ignored). No pin: require a clean Windows
|
||||||
validation.
|
validation.
|
||||||
@@ -702,7 +714,20 @@ Read needs `endpoints.fleet`; writes need the admin role and are audited.
|
|||||||
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`)
|
- `GET /api/endpoints/enrollment` (`page`, `perpage` <= 100, `state`, `search`)
|
||||||
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
|
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
|
||||||
- `POST /api/endpoints/enrollment/requests/<id>/reject`
|
- `POST /api/endpoints/enrollment/requests/<id>/reject`
|
||||||
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll)
|
- `POST /api/endpoints/enrollment/assets/<assetid>/revoke` (un-enroll). Optional
|
||||||
|
body `{"blockreenroll": true, "reason": "rollback"}` (`reason` <= 64 chars,
|
||||||
|
used only with `blockreenroll`) also keeps the PC from enrolling again: its
|
||||||
|
enroll requests stay pending with `reenroll blocked`, and the Migration tab
|
||||||
|
shows it blocked with the same blocker. Works on a PC whose credential is
|
||||||
|
already revoked. Without the block a 1.20 agent re-enrolls within about a
|
||||||
|
second of a plain revoke.
|
||||||
|
- `POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` lifts the
|
||||||
|
block; the PC enrolls on its next attempt. 404 when the PC has no credential
|
||||||
|
record; a PC that was not blocked returns success unchanged.
|
||||||
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
|
- `PUT /api/endpoints/enrollment/settings` `{"sharedkeyallowed": bool, "autoenroll": bool}`
|
||||||
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
|
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
|
||||||
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)
|
- `POST /api/endpoints/enrollment/migration/wave` `{"assetids": [...]}` or `{"next": n}` (n <= 100)
|
||||||
|
- Migration blockers include `server certificate not trusted by this PC`: the
|
||||||
|
PC's last upgrade download failed on the certificate chain (the server only
|
||||||
|
knows from the failure the PC reported). Trust the certificate
|
||||||
|
(`Install-ShopDBServerCertificate.ps1`) and queue it again.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ and, when the route names a PC, binds the caller through bind_hostname().
|
|||||||
X-API-Key The shared fleet key (or a collector-scoped managed
|
X-API-Key The shared fleet key (or a collector-scoped managed
|
||||||
token). Accepted only while
|
token). Accepted only while
|
||||||
plugin.endpoints.sharedkeyallowed is on, and never for a
|
plugin.endpoints.sharedkeyallowed is on, and never for a
|
||||||
PC that holds an active credential.
|
PC that holds an active credential. A PC blocked
|
||||||
|
from re-enrolling has no active credential, so an
|
||||||
|
older agent on it keeps working on the shared key.
|
||||||
|
|
||||||
Only the sha256 of a credential is stored. The full secret exists once, in the
|
Only the sha256 of a credential is stored. The full secret exists once, in the
|
||||||
enroll response.
|
enroll response.
|
||||||
@@ -79,6 +81,18 @@ def active_credential_for(assetid):
|
|||||||
.first())
|
.first())
|
||||||
|
|
||||||
|
|
||||||
|
def reenroll_blocked(assetid):
|
||||||
|
"""True when an admin blocked this asset from enrolling again."""
|
||||||
|
from ..models import EndpointCredential
|
||||||
|
|
||||||
|
if assetid is None:
|
||||||
|
return False
|
||||||
|
return (EndpointCredential.query
|
||||||
|
.filter(EndpointCredential.assetid == assetid,
|
||||||
|
EndpointCredential.reenrollblocked.is_(True))
|
||||||
|
.first()) is not None
|
||||||
|
|
||||||
|
|
||||||
def resolve_credential(secret, touch=True):
|
def resolve_credential(secret, touch=True):
|
||||||
"""Asset id for an active credential, else None."""
|
"""Asset id for an active credential, else None."""
|
||||||
from ..models import EndpointCredential
|
from ..models import EndpointCredential
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ Agent-facing:
|
|||||||
Admin (endpoints.fleet to read, admin role to change):
|
Admin (endpoints.fleet to read, admin role to change):
|
||||||
GET /enrollment credential state per computer + requests
|
GET /enrollment credential state per computer + requests
|
||||||
POST /enrollment/requests/<id>/approve | reject
|
POST /enrollment/requests/<id>/approve | reject
|
||||||
POST /enrollment/assets/<assetid>/revoke
|
POST /enrollment/assets/<assetid>/revoke body {"blockreenroll", "reason"}
|
||||||
|
POST /enrollment/assets/<assetid>/allow-reenroll lift a re-enrollment block
|
||||||
PUT /enrollment/settings
|
PUT /enrollment/settings
|
||||||
GET /enrollment/migration readiness of every computer for agent 1.20
|
GET /enrollment/migration readiness of every computer for agent 1.20
|
||||||
POST /enrollment/migration/wave push the agent upgrade to a batch of PCs
|
POST /enrollment/migration/wave push the agent upgrade to a batch of PCs
|
||||||
@@ -58,6 +59,7 @@ REASON_ALREADY_ENROLLED = 'already enrolled'
|
|||||||
REASON_AWAITING_APPROVAL = 'awaiting approval'
|
REASON_AWAITING_APPROVAL = 'awaiting approval'
|
||||||
REASON_NONCE_CHANGED = 'nonce changed'
|
REASON_NONCE_CHANGED = 'nonce changed'
|
||||||
REASON_REJECTED = 'rejected'
|
REASON_REJECTED = 'rejected'
|
||||||
|
REASON_REENROLL_BLOCKED = 'reenroll blocked'
|
||||||
|
|
||||||
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
|
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
|
||||||
'notreporting')
|
'notreporting')
|
||||||
@@ -71,6 +73,18 @@ BLOCKER_NO_PACKAGE = 'no agent package in store'
|
|||||||
BLOCKER_TOO_OLD = 'agent too old for remote upgrade'
|
BLOCKER_TOO_OLD = 'agent too old for remote upgrade'
|
||||||
BLOCKER_POLICY = 'run_script not permitted by policy'
|
BLOCKER_POLICY = 'run_script not permitted by policy'
|
||||||
BLOCKER_AWAITING_ENROLLMENT = 'awaiting enrollment'
|
BLOCKER_AWAITING_ENROLLMENT = 'awaiting enrollment'
|
||||||
|
BLOCKER_REENROLL_BLOCKED = 'reenroll blocked'
|
||||||
|
BLOCKER_CERT_UNTRUSTED = 'server certificate not trusted by this PC'
|
||||||
|
|
||||||
|
# How agents up to 1.19 word a download refused over the server certificate.
|
||||||
|
# Only a PC's own recorded failure shows it; the server cannot read a trust store.
|
||||||
|
_TLS_TRUST_FAILURES = (
|
||||||
|
'ssl connection could not be established',
|
||||||
|
'untrustedroot',
|
||||||
|
'remotecertificatechainerrors',
|
||||||
|
'the remote certificate is invalid',
|
||||||
|
'could not establish trust relationship',
|
||||||
|
)
|
||||||
|
|
||||||
_OFFLINE_AFTER = timedelta(hours=24)
|
_OFFLINE_AFTER = timedelta(hours=24)
|
||||||
_WAVE_MAXIMUM = 100
|
_WAVE_MAXIMUM = 100
|
||||||
@@ -224,6 +238,8 @@ def enroll_endpoint():
|
|||||||
'installation', http_code=403)
|
'installation', http_code=403)
|
||||||
if agentauth.active_credential_for(row.matchedassetid) is not None:
|
if agentauth.active_credential_for(row.matchedassetid) is not None:
|
||||||
return _pending(row, REASON_ALREADY_ENROLLED, row.matchedassetid)
|
return _pending(row, REASON_ALREADY_ENROLLED, row.matchedassetid)
|
||||||
|
if agentauth.reenroll_blocked(row.matchedassetid):
|
||||||
|
return _pending(row, REASON_REENROLL_BLOCKED, row.matchedassetid)
|
||||||
if row.matchedassetid is None or db.session.get(Asset, row.matchedassetid) is None:
|
if row.matchedassetid is None or db.session.get(Asset, row.matchedassetid) is None:
|
||||||
return _pending(row, REASON_UNKNOWN_HOSTNAME)
|
return _pending(row, REASON_UNKNOWN_HOSTNAME)
|
||||||
return _issue(row, row.matchedassetid,
|
return _issue(row, row.matchedassetid,
|
||||||
@@ -274,6 +290,8 @@ def enroll_endpoint():
|
|||||||
return _pending(row, REASON_SERIAL_MISMATCH, assetid)
|
return _pending(row, REASON_SERIAL_MISMATCH, assetid)
|
||||||
if agentauth.active_credential_for(assetid) is not None:
|
if agentauth.active_credential_for(assetid) is not None:
|
||||||
return _pending(row, REASON_ALREADY_ENROLLED, assetid)
|
return _pending(row, REASON_ALREADY_ENROLLED, assetid)
|
||||||
|
if agentauth.reenroll_blocked(assetid):
|
||||||
|
return _pending(row, REASON_REENROLL_BLOCKED, assetid)
|
||||||
if sharedkeyauthenticated and agentauth.auto_enroll_enabled():
|
if sharedkeyauthenticated and agentauth.auto_enroll_enabled():
|
||||||
return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO)
|
return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO)
|
||||||
return _pending(row, REASON_AWAITING_APPROVAL, assetid)
|
return _pending(row, REASON_AWAITING_APPROVAL, assetid)
|
||||||
@@ -347,8 +365,12 @@ def get_enrollment():
|
|||||||
rows = []
|
rows = []
|
||||||
for assetid, hostname, serialnumber in _computers():
|
for assetid, hostname, serialnumber in _computers():
|
||||||
credential = credentials.get(assetid)
|
credential = credentials.get(assetid)
|
||||||
|
blocked = bool(credential is not None and credential.reenrollblocked)
|
||||||
if credential is not None and credential.revokedat is None:
|
if credential is not None and credential.revokedat is None:
|
||||||
state = 'enrolled'
|
state = 'enrolled'
|
||||||
|
elif blocked:
|
||||||
|
# Its agent keeps asking; those requests are expected, not pending work.
|
||||||
|
state = 'revoked'
|
||||||
elif _is_pending(assetid, hostname, index):
|
elif _is_pending(assetid, hostname, index):
|
||||||
state = 'pending'
|
state = 'pending'
|
||||||
elif credential is not None:
|
elif credential is not None:
|
||||||
@@ -364,6 +386,9 @@ def get_enrollment():
|
|||||||
'issuedat': _utciso(credential.issuedat) if credential else None,
|
'issuedat': _utciso(credential.issuedat) if credential else None,
|
||||||
'lastusedat': _utciso(credential.lastusedat) if credential else None,
|
'lastusedat': _utciso(credential.lastusedat) if credential else None,
|
||||||
'enrolledvia': credential.enrolledvia if credential else None,
|
'enrolledvia': credential.enrolledvia if credential else None,
|
||||||
|
'reenrollblocked': blocked,
|
||||||
|
'reenrollblockedreason': (credential.reenrollblockedreason
|
||||||
|
if blocked else None),
|
||||||
})
|
})
|
||||||
|
|
||||||
counts = Counter(row['state'] for row in rows)
|
counts = Counter(row['state'] for row in rows)
|
||||||
@@ -430,6 +455,10 @@ def approve_enroll_request(requestid):
|
|||||||
return error_response(ErrorCodes.CONFLICT,
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
'That computer is already enrolled; revoke its '
|
'That computer is already enrolled; revoke its '
|
||||||
'credential first', http_code=409)
|
'credential first', http_code=409)
|
||||||
|
if agentauth.reenroll_blocked(assetid):
|
||||||
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
|
'That computer is blocked from enrolling again; '
|
||||||
|
'allow re-enrollment first', http_code=409)
|
||||||
|
|
||||||
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
row.status = EndpointEnrollRequest.STATUS_APPROVED
|
||||||
row.matchedassetid = assetid
|
row.matchedassetid = assetid
|
||||||
@@ -467,23 +496,83 @@ def reject_enroll_request(requestid):
|
|||||||
@endpoints_bp.route('/enrollment/assets/<int:assetid>/revoke', methods=['POST'])
|
@endpoints_bp.route('/enrollment/assets/<int:assetid>/revoke', methods=['POST'])
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
def revoke_endpoint_credential(assetid):
|
def revoke_endpoint_credential(assetid):
|
||||||
"""Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again."""
|
"""Un-enroll a PC. Its agent gets CREDENTIAL_INVALID and enrolls again.
|
||||||
|
|
||||||
|
Body (optional) {"blockreenroll": true, "reason": "rollback"} also keeps the
|
||||||
|
PC from enrolling again until POST .../allow-reenroll. That is the step
|
||||||
|
before rolling a PC back to an agent that only knows the shared key: without
|
||||||
|
it the current agent re-enrolls within seconds and the older agent is then
|
||||||
|
refused. A PC whose credential is already revoked can still be blocked.
|
||||||
|
"""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Body must be a JSON object')
|
||||||
|
blockreenroll = payload.get('blockreenroll', False)
|
||||||
|
if not isinstance(blockreenroll, bool):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'blockreenroll must be true or false')
|
||||||
|
reason = None
|
||||||
|
if blockreenroll:
|
||||||
|
reason, problem = _text_field(payload, 'reason', 64, False)
|
||||||
|
if problem is not None:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
||||||
|
reason = reason or None
|
||||||
|
|
||||||
credential = agentauth.active_credential_for(assetid)
|
credential = agentauth.active_credential_for(assetid)
|
||||||
|
if credential is None and blockreenroll:
|
||||||
|
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
|
||||||
if credential is None:
|
if credential is None:
|
||||||
return error_response(ErrorCodes.NOT_FOUND,
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
'This computer has no active credential',
|
'This computer has no active credential',
|
||||||
http_code=404)
|
http_code=404)
|
||||||
|
|
||||||
|
wasactive = credential.revokedat is None
|
||||||
|
if wasactive:
|
||||||
credential.revokedat = _naive_utcnow()
|
credential.revokedat = _naive_utcnow()
|
||||||
credential.revokedby = _username()
|
credential.revokedby = _username()
|
||||||
AuditLog.log('deleted', 'EndpointCredential', entityid=assetid,
|
details = []
|
||||||
entityname=credential.credentialprefix,
|
if wasactive:
|
||||||
details='endpoint credential {} revoked'.format(
|
details.append('endpoint credential {} revoked'.format(
|
||||||
credential.credentialprefix))
|
credential.credentialprefix))
|
||||||
|
if blockreenroll:
|
||||||
|
credential.reenrollblocked = True
|
||||||
|
credential.reenrollblockedreason = reason
|
||||||
|
details.append('re-enrollment blocked ({})'.format(reason or 'no reason'))
|
||||||
|
AuditLog.log('deleted' if wasactive else 'updated', 'EndpointCredential',
|
||||||
|
entityid=assetid, entityname=credential.credentialprefix,
|
||||||
|
details='; '.join(details))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(credential.to_dict(),
|
return success_response(credential.to_dict(),
|
||||||
message='Endpoint credential revoked')
|
message='Endpoint credential revoked')
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/enrollment/assets/<int:assetid>/allow-reenroll',
|
||||||
|
methods=['POST'])
|
||||||
|
@require_role('admin')
|
||||||
|
def allow_endpoint_reenroll(assetid):
|
||||||
|
"""Lift a re-enrollment block. The PC enrolls on its next attempt."""
|
||||||
|
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
|
||||||
|
if credential is None:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'This computer has no credential record',
|
||||||
|
http_code=404)
|
||||||
|
if not credential.reenrollblocked:
|
||||||
|
return success_response(credential.to_dict(),
|
||||||
|
message='Re-enrollment was not blocked')
|
||||||
|
previous = credential.reenrollblockedreason
|
||||||
|
credential.reenrollblocked = False
|
||||||
|
credential.reenrollblockedreason = None
|
||||||
|
AuditLog.log('updated', 'EndpointCredential', entityid=assetid,
|
||||||
|
entityname=credential.credentialprefix,
|
||||||
|
changes={'reenrollblocked': {'old': True, 'new': False}},
|
||||||
|
details='re-enrollment allowed again (was blocked: {})'.format(
|
||||||
|
previous or 'no reason'))
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(credential.to_dict(),
|
||||||
|
message='Re-enrollment allowed')
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
@endpoints_bp.route('/enrollment/settings', methods=['PUT'])
|
||||||
@require_role('admin')
|
@require_role('admin')
|
||||||
def update_enrollment_settings():
|
def update_enrollment_settings():
|
||||||
@@ -546,6 +635,28 @@ def _store_has_package():
|
|||||||
return not _version_below(current, CREDENTIAL_AGENT_VERSION)
|
return not _version_below(current, CREDENTIAL_AGENT_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_commands(assetids, target=None):
|
||||||
|
"""The newest run_script command per asset (optionally for one target)."""
|
||||||
|
if not assetids:
|
||||||
|
return {}
|
||||||
|
newest = (db.session.query(db.func.max(EndpointCommand.endpointcommandid))
|
||||||
|
.filter(EndpointCommand.assetid.in_(assetids),
|
||||||
|
EndpointCommand.action == 'run_script'))
|
||||||
|
if target is not None:
|
||||||
|
newest = newest.filter(EndpointCommand.target == target)
|
||||||
|
newest = newest.group_by(EndpointCommand.assetid)
|
||||||
|
return {command.assetid: command for command in EndpointCommand.query
|
||||||
|
.filter(EndpointCommand.endpointcommandid.in_(newest)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tls_trust_failure(command):
|
||||||
|
if command is None or command.status != EndpointCommand.STATUS_FAILED:
|
||||||
|
return False
|
||||||
|
text = '{} {}'.format(command.resultmessage or '',
|
||||||
|
command.resultjson or '').lower()
|
||||||
|
return any(marker in text for marker in _TLS_TRUST_FAILURES)
|
||||||
|
|
||||||
|
|
||||||
def compute_readiness(assetids=None):
|
def compute_readiness(assetids=None):
|
||||||
"""Migration state for every active computer (or just assetids).
|
"""Migration state for every active computer (or just assetids).
|
||||||
|
|
||||||
@@ -562,8 +673,11 @@ def compute_readiness(assetids=None):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
ids = [row[0] for row in computers]
|
ids = [row[0] for row in computers]
|
||||||
active = {c.assetid for c in EndpointCredential.query
|
credentialrows = EndpointCredential.query.filter(
|
||||||
.filter(EndpointCredential.revokedat.is_(None)).all()}
|
EndpointCredential.assetid.in_(ids)).all()
|
||||||
|
active = {c.assetid for c in credentialrows if c.revokedat is None}
|
||||||
|
reenrollblocked = {c.assetid for c in credentialrows
|
||||||
|
if c.reenrollblocked and c.revokedat is not None}
|
||||||
index = _pending_index(_pending_requests())
|
index = _pending_index(_pending_requests())
|
||||||
states = {s.assetid: s for s in
|
states = {s.assetid: s for s in
|
||||||
EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()}
|
EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()}
|
||||||
@@ -579,6 +693,8 @@ def compute_readiness(assetids=None):
|
|||||||
(EndpointCommand.STATUS_PENDING,
|
(EndpointCommand.STATUS_PENDING,
|
||||||
EndpointCommand.STATUS_RUNNING)))}
|
EndpointCommand.STATUS_RUNNING)))}
|
||||||
|
|
||||||
|
lastscripts = _latest_commands(ids)
|
||||||
|
|
||||||
haspackage = _store_has_package()
|
haspackage = _store_has_package()
|
||||||
minimum = minimum_agent_version_for('run_script')
|
minimum = minimum_agent_version_for('run_script')
|
||||||
offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER
|
offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER
|
||||||
@@ -591,6 +707,9 @@ def compute_readiness(assetids=None):
|
|||||||
blockers = []
|
blockers = []
|
||||||
if assetid in active:
|
if assetid in active:
|
||||||
status = 'enrolled'
|
status = 'enrolled'
|
||||||
|
elif assetid in reenrollblocked:
|
||||||
|
status = 'blocked'
|
||||||
|
blockers.append(BLOCKER_REENROLL_BLOCKED)
|
||||||
elif _is_pending(assetid, hostname, index):
|
elif _is_pending(assetid, hostname, index):
|
||||||
status = 'pending'
|
status = 'pending'
|
||||||
elif assetid in upgrading:
|
elif assetid in upgrading:
|
||||||
@@ -610,6 +729,8 @@ def compute_readiness(assetids=None):
|
|||||||
blockers.append(BLOCKER_NO_PACKAGE)
|
blockers.append(BLOCKER_NO_PACKAGE)
|
||||||
if minimum and not agent_meets_minimum(agentversion, minimum):
|
if minimum and not agent_meets_minimum(agentversion, minimum):
|
||||||
blockers.append(BLOCKER_TOO_OLD)
|
blockers.append(BLOCKER_TOO_OLD)
|
||||||
|
if _is_tls_trust_failure(lastscripts.get(assetid)):
|
||||||
|
blockers.append(BLOCKER_CERT_UNTRUSTED)
|
||||||
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
|
if not _version_below(agentversion, CREDENTIAL_AGENT_VERSION):
|
||||||
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
|
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
|
||||||
if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR(
|
if not blockers and 'run_script' not in ALLOWED_ACTIONS_FOR(
|
||||||
@@ -628,6 +749,13 @@ def compute_readiness(assetids=None):
|
|||||||
|
|
||||||
|
|
||||||
def _wave_progress(wave, scriptid):
|
def _wave_progress(wave, scriptid):
|
||||||
|
"""Live progress of one wave.
|
||||||
|
|
||||||
|
failed counts a member only while it is still stuck: its newest agent
|
||||||
|
upgrade (from any wave or a single push) failed or expired, it is not
|
||||||
|
enrolled, and it does not report an agent at or above the credential
|
||||||
|
version. A PC a later wave upgraded stops counting here.
|
||||||
|
"""
|
||||||
members = wave.assetidlist
|
members = wave.assetidlist
|
||||||
enrolled = set()
|
enrolled = set()
|
||||||
failed = set()
|
failed = set()
|
||||||
@@ -637,21 +765,23 @@ def _wave_progress(wave, scriptid):
|
|||||||
EndpointCredential.assetid.in_(members),
|
EndpointCredential.assetid.in_(members),
|
||||||
EndpointCredential.revokedat.is_(None)).all()}
|
EndpointCredential.revokedat.is_(None)).all()}
|
||||||
if scriptid is not None:
|
if scriptid is not None:
|
||||||
commands = (EndpointCommand.query
|
target = f'script:{scriptid}'
|
||||||
|
queued = {row[0] for row in db.session.query(EndpointCommand.assetid)
|
||||||
.filter(EndpointCommand.assetid.in_(members),
|
.filter(EndpointCommand.assetid.in_(members),
|
||||||
EndpointCommand.action == 'run_script',
|
EndpointCommand.action == 'run_script',
|
||||||
EndpointCommand.target == f'script:{scriptid}',
|
EndpointCommand.target == target,
|
||||||
EndpointCommand.createdat >= wave.createdat)
|
EndpointCommand.createdat >= wave.createdat)
|
||||||
.order_by(EndpointCommand.createdat.asc())
|
.distinct()}
|
||||||
.all())
|
versions = dict(db.session.query(EndpointState.assetid,
|
||||||
latest = {}
|
EndpointState.agentversion)
|
||||||
for command in commands:
|
.filter(EndpointState.assetid.in_(members)).all())
|
||||||
queued.add(command.assetid)
|
failed = {assetid for assetid, command
|
||||||
latest[command.assetid] = command
|
in _latest_commands(members, target).items()
|
||||||
failed = {assetid for assetid, command in latest.items()
|
|
||||||
if assetid not in enrolled
|
if assetid not in enrolled
|
||||||
and command.status in (EndpointCommand.STATUS_FAILED,
|
and command.status in (EndpointCommand.STATUS_FAILED,
|
||||||
EndpointCommand.STATUS_EXPIRED)}
|
EndpointCommand.STATUS_EXPIRED)
|
||||||
|
and _version_below(versions.get(assetid),
|
||||||
|
CREDENTIAL_AGENT_VERSION)}
|
||||||
return {
|
return {
|
||||||
'waveid': wave.waveid,
|
'waveid': wave.waveid,
|
||||||
'createdat': _utciso(wave.createdat),
|
'createdat': _utciso(wave.createdat),
|
||||||
|
|||||||
@@ -4164,6 +4164,14 @@ def acknowledge_fleet_alert(notification_id: int):
|
|||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'hostname does not match provided assetid')
|
'hostname does not match provided assetid')
|
||||||
|
|
||||||
|
# Same targeting as the /alerts list. An alert this PC was never shown
|
||||||
|
# answers exactly like an unknown one, so the route cannot probe scopes.
|
||||||
|
facts = _endpoint_scope_facts(hostname) or {}
|
||||||
|
if not _alert_targets_endpoint(notification.alertscope, facts):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Notification {notification_id} not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
# Check if already acknowledged (idempotent)
|
# Check if already acknowledged (idempotent)
|
||||||
existing = (NotificationAcknowledgement.query
|
existing = (NotificationAcknowledgement.query
|
||||||
.filter_by(notificationid=notification_id, assetid=assetid)
|
.filter_by(notificationid=notification_id, assetid=assetid)
|
||||||
@@ -4196,6 +4204,21 @@ def acknowledge_fleet_alert(notification_id: int):
|
|||||||
# Server certificate management
|
# Server certificate management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def _sha1_thumbprint(pembytes):
|
||||||
|
"""Uppercase hex SHA-1 of a PEM certificate, as Windows shows a thumbprint.
|
||||||
|
|
||||||
|
None when the file does not parse.
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
|
try:
|
||||||
|
certificate = x509.load_pem_x509_certificate(pembytes)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return certificate.fingerprint(hashes.SHA1()).hex().upper()
|
||||||
|
|
||||||
|
|
||||||
def _spki_sha256(pembytes):
|
def _spki_sha256(pembytes):
|
||||||
"""Lowercase hex SHA-256 of a PEM certificate's SubjectPublicKeyInfo.
|
"""Lowercase hex SHA-256 of a PEM certificate's SubjectPublicKeyInfo.
|
||||||
|
|
||||||
@@ -4299,6 +4322,7 @@ def get_server_certificate():
|
|||||||
'not_after': not_after,
|
'not_after': not_after,
|
||||||
'thumbprint': thumbprint,
|
'thumbprint': thumbprint,
|
||||||
'spkisha256': _spki_sha256(cert_path.read_bytes()),
|
'spkisha256': _spki_sha256(cert_path.read_bytes()),
|
||||||
|
'sha1thumbprint': _sha1_thumbprint(cert_path.read_bytes()),
|
||||||
'san_list': san_list,
|
'san_list': san_list,
|
||||||
'expires_in_days': expires_in_days,
|
'expires_in_days': expires_in_days,
|
||||||
'is_self_signed': is_self_signed
|
'is_self_signed': is_self_signed
|
||||||
@@ -4316,6 +4340,36 @@ def get_server_certificate():
|
|||||||
http_code=500)
|
http_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/settings/server-cert/download', methods=['GET'])
|
||||||
|
@require_permission('endpoints.fleet')
|
||||||
|
def download_server_certificate():
|
||||||
|
"""The PUBLIC server certificate as a DER .cer file, never the key.
|
||||||
|
|
||||||
|
For PCs on agents older than 1.20, which check the certificate chain when
|
||||||
|
they download the upgrade script: import this into the PC's Trusted Root
|
||||||
|
store before a migration wave reaches it.
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
cert_path = _server_cert_dir() / 'server.crt'
|
||||||
|
if not cert_path.exists():
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'Server certificate not found', http_code=404)
|
||||||
|
try:
|
||||||
|
certificate = x509.load_pem_x509_certificate(cert_path.read_bytes())
|
||||||
|
except ValueError:
|
||||||
|
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||||
|
'The server certificate file does not parse',
|
||||||
|
http_code=500)
|
||||||
|
body = certificate.public_bytes(serialization.Encoding.DER)
|
||||||
|
response = current_app.response_class(body, mimetype='application/pkix-cert')
|
||||||
|
response.headers['Content-Disposition'] = \
|
||||||
|
'attachment; filename="shopdb-server.cer"'
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
_CERT_HOSTNAME_PATTERN = re.compile(
|
_CERT_HOSTNAME_PATTERN = re.compile(
|
||||||
r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
r'^(\*\.)?(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
||||||
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$')
|
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$')
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ The generated install.ps1 writes those two registry values from the operator's
|
|||||||
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
|
inputs; appsettings.json ships with an empty baseurl and a placeholder apikey so
|
||||||
the agent fails closed until provisioned. The collector key is embedded only in
|
the agent fails closed until provisioned. The collector key is embedded only in
|
||||||
the install script's default parameter and is never stored or logged here.
|
the install script's default parameter and is never stored or logged here.
|
||||||
|
|
||||||
|
Per-PC credentials: the shared key is optional per build. A package built
|
||||||
|
without it carries no key at all; the agent then asks to enroll and an admin
|
||||||
|
approves it. Every package pins the server's public key (ServerPins, the
|
||||||
|
lowercase hex SHA-256 of the leaf SubjectPublicKeyInfo), optionally with a
|
||||||
|
second "next" pin for a planned certificate rotation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import ssl
|
import ssl
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
@@ -34,9 +41,12 @@ from flask import request, current_app, send_file
|
|||||||
from shopdb.api import (success_response, error_response, ErrorCodes,
|
from shopdb.api import (success_response, error_response, ErrorCodes,
|
||||||
require_permission)
|
require_permission)
|
||||||
|
|
||||||
from .routes import endpoints_bp
|
from .routes import endpoints_bp, _spki_sha256
|
||||||
|
from .agentauth import shared_key_allowed
|
||||||
from . import agentstore
|
from . import agentstore
|
||||||
|
|
||||||
|
_PIN_PATTERN = re.compile(r'^[0-9a-f]{64}$')
|
||||||
|
|
||||||
# Prebuilt, staged agent binaries. Built out of band and dropped under
|
# Prebuilt, staged agent binaries. Built out of band and dropped under
|
||||||
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
|
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
|
||||||
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
||||||
@@ -110,6 +120,49 @@ def _validate_pem_cert(raw):
|
|||||||
return None
|
return None
|
||||||
return ''.join(blocks)
|
return ''.join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _leaf_certificate_facts(pembytes):
|
||||||
|
"""(spkisha256, isca) for the FIRST certificate in a PEM, else (None, None).
|
||||||
|
|
||||||
|
The first block is the leaf in a leaf-plus-chain file, which is the cert
|
||||||
|
the agent pins and the one Import-Certificate brings into the store.
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = pembytes.decode('ascii')
|
||||||
|
except (UnicodeDecodeError, AttributeError):
|
||||||
|
return None, None
|
||||||
|
start = text.find('-----BEGIN CERTIFICATE-----')
|
||||||
|
end = text.find('-----END CERTIFICATE-----', start)
|
||||||
|
if start == -1 or end == -1:
|
||||||
|
return None, None
|
||||||
|
block = (text[start:end + len('-----END CERTIFICATE-----')] + '\n').encode('ascii')
|
||||||
|
try:
|
||||||
|
certificate = x509.load_pem_x509_certificate(block)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
isca = bool(certificate.extensions.get_extension_for_class(
|
||||||
|
x509.BasicConstraints).value.ca)
|
||||||
|
except x509.ExtensionNotFound:
|
||||||
|
isca = False
|
||||||
|
return _spki_sha256(block), isca
|
||||||
|
|
||||||
|
|
||||||
|
def _current_server_pin():
|
||||||
|
"""Pin of this server's own leaf certificate, or None when not found/CA."""
|
||||||
|
path = _server_cert_path()
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as handle:
|
||||||
|
pin, isca = _leaf_certificate_facts(handle.read())
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None if isca else pin
|
||||||
|
|
||||||
|
|
||||||
# The non-secret runtime config shipped in the package. baseurl and apikey stay
|
# The non-secret runtime config shipped in the package. baseurl and apikey stay
|
||||||
# empty/placeholder so the agent fails closed until the registry is provisioned
|
# empty/placeholder so the agent fails closed until the registry is provisioned
|
||||||
# by install.ps1. Cadence is stamped from the operator's input at build time.
|
# by install.ps1. Cadence is stamped from the operator's input at build time.
|
||||||
@@ -175,20 +228,42 @@ FILES
|
|||||||
USAGE
|
USAGE
|
||||||
1. Copy this whole folder to the target endpoint.
|
1. Copy this whole folder to the target endpoint.
|
||||||
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
2. Right-click install.ps1 and Run with PowerShell as administrator. It
|
||||||
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
|
{provision} to HKLM\\SOFTWARE\\GE\\ShopDB,
|
||||||
copies the agent into Program Files, and starts the service. It also
|
copies the agent into Program Files, and starts the service. It also
|
||||||
registers the tray to autostart for every user at logon and adds a
|
registers the tray to autostart for every user at logon and adds a
|
||||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||||
|
{enrollment}
|
||||||
UNINSTALL
|
UNINSTALL
|
||||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||||
|
|
||||||
SECURITY
|
SECURITY
|
||||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
{security}"""
|
||||||
Treat this package as a secret. The key is not recoverable from the ShopDB
|
|
||||||
server after it was generated; regenerate the key in the UI to revoke it.
|
_README_SHAREDKEY = {
|
||||||
"""
|
True: {
|
||||||
|
'provision': 'writes the server URL, server pins, and collector key',
|
||||||
|
'enrollment': '',
|
||||||
|
'security': (
|
||||||
|
' install.ps1 carries a live collector key in its default -CollectorKey value.\n'
|
||||||
|
' Treat this package as a secret. The key is not recoverable from the ShopDB\n'
|
||||||
|
' server after it was generated; regenerate the key in the UI to revoke it.\n'),
|
||||||
|
},
|
||||||
|
False: {
|
||||||
|
'provision': 'writes the server URL and server pins',
|
||||||
|
'enrollment': (
|
||||||
|
' This package carries NO shared key. After install the PC requests\n'
|
||||||
|
' enrollment; approve it in Settings > Endpoints > Enrollment.\n'),
|
||||||
|
'security': (
|
||||||
|
' This package holds no secret. Each PC receives its own credential when\n'
|
||||||
|
' an admin approves its enrollment request; an existing enrolled\n'
|
||||||
|
' credential on the PC is never touched by install.ps1.\n'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _readme(includesharedkey):
|
||||||
|
return _README.format(**_README_SHAREDKEY[bool(includesharedkey)])
|
||||||
|
|
||||||
|
|
||||||
def _staged_path(relpath):
|
def _staged_path(relpath):
|
||||||
@@ -196,7 +271,9 @@ def _staged_path(relpath):
|
|||||||
return os.path.join(current_app.instance_path, relpath)
|
return os.path.join(current_app.instance_path, relpath)
|
||||||
|
|
||||||
|
|
||||||
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None,
|
||||||
|
serverpins=None, includesharedkey=True,
|
||||||
|
trustcertinroot=True):
|
||||||
"""The install.ps1 that provisions credentials and installs the service.
|
"""The install.ps1 that provisions credentials and installs the service.
|
||||||
|
|
||||||
The operator's server URL and collector key are stamped as the default
|
The operator's server URL and collector key are stamped as the default
|
||||||
@@ -206,10 +283,34 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
|||||||
certificate is bundled, certfilename names it and the script imports it into
|
certificate is bundled, certfilename names it and the script imports it into
|
||||||
the machine Trusted Root store so the agent's outbound TLS to a self-signed
|
the machine Trusted Root store so the agent's outbound TLS to a self-signed
|
||||||
or internal-CA server validates.
|
or internal-CA server validates.
|
||||||
|
|
||||||
|
serverpins are written as REG_MULTI_SZ ServerPins. With includesharedkey
|
||||||
|
False the collector key is left out of the script entirely (no parameter,
|
||||||
|
no registry write) and the installer tells the operator to approve the
|
||||||
|
enrollment request. trustcertinroot False (a CA certificate) skips the Root
|
||||||
|
import; the script also re-checks basicConstraints on the endpoint.
|
||||||
"""
|
"""
|
||||||
baseurl_literal = json.dumps(baseurl)
|
baseurl_literal = json.dumps(baseurl)
|
||||||
collectorkey_literal = json.dumps(collectorkey)
|
|
||||||
certfile_literal = json.dumps(certfilename or '')
|
certfile_literal = json.dumps(certfilename or '')
|
||||||
|
pins_literal = '@(' + ', '.join(json.dumps(pin) for pin in (serverpins or [])) + ')'
|
||||||
|
if includesharedkey:
|
||||||
|
keyparam = ' [string] $CollectorKey = {0},\n'.format(json.dumps(collectorkey))
|
||||||
|
keycheck = (
|
||||||
|
'if ([string]::IsNullOrWhiteSpace($CollectorKey)) {\n'
|
||||||
|
' throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."\n'
|
||||||
|
'}\n')
|
||||||
|
keywrite = 'Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey\n'
|
||||||
|
sharedkeymode = 'included'
|
||||||
|
enrollnotice = ''
|
||||||
|
else:
|
||||||
|
keyparam = ''
|
||||||
|
keycheck = ''
|
||||||
|
keywrite = (
|
||||||
|
'Write-Host " no shared key in this package; the agent will request enrollment." -ForegroundColor Yellow\n')
|
||||||
|
sharedkeymode = 'omitted'
|
||||||
|
enrollnotice = (
|
||||||
|
'Write-Host " This PC will request enrollment. Approve it in '
|
||||||
|
'Settings > Endpoints > Enrollment." -ForegroundColor Yellow\n')
|
||||||
return """# install.ps1
|
return """# install.ps1
|
||||||
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
|
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
|
||||||
# the endpoint. Provisions the registry credentials, copies the agent to
|
# the endpoint. Provisions the registry credentials, copies the agent to
|
||||||
@@ -219,7 +320,7 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
|||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string] $BaseUrl = {baseurl},
|
[string] $BaseUrl = {baseurl},
|
||||||
[string] $CollectorKey = {collectorkey},
|
{keyparam} [string[]] $ServerPins = {serverpins},
|
||||||
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
|
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
|
||||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||||
@@ -251,9 +352,8 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
|||||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
|
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
|
||||||
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
||||||
}}
|
}}
|
||||||
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
|
{keycheck}$sharedKeyMode = "{sharedkeymode}"
|
||||||
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
|
$trustCertInRoot = {trustcert}
|
||||||
}}
|
|
||||||
|
|
||||||
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
|
# Write HKLM values to BOTH the 64-bit and 32-bit (WOW6432Node) registry views.
|
||||||
# The agent reads Registry64 first then Registry32 and uses the first view that
|
# The agent reads Registry64 first then Registry32 and uses the first view that
|
||||||
@@ -280,10 +380,55 @@ function Set-HklmValueBothViews {{
|
|||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
# REG_MULTI_SZ in both views, compare-before-write so a reinstall with the same
|
||||||
|
# pins leaves the value (and its last-write time) alone.
|
||||||
|
function Set-HklmMultiStringBothViews {{
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string] $SubKey,
|
||||||
|
[Parameter(Mandatory)] [string] $Name,
|
||||||
|
[Parameter(Mandatory)] [string[]] $Value
|
||||||
|
)
|
||||||
|
$wanted = ($Value -join "`n")
|
||||||
|
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
||||||
|
[Microsoft.Win32.RegistryView]::Registry32)) {{
|
||||||
|
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||||
|
[Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||||
|
try {{
|
||||||
|
$key = $base.CreateSubKey($SubKey)
|
||||||
|
try {{
|
||||||
|
$same = $false
|
||||||
|
$existingValue = $key.GetValue($Name, $null)
|
||||||
|
if ($null -ne $existingValue -and
|
||||||
|
$key.GetValueKind($Name) -eq [Microsoft.Win32.RegistryValueKind]::MultiString) {{
|
||||||
|
$same = ((@($existingValue) -join "`n") -ceq $wanted)
|
||||||
|
}}
|
||||||
|
if ($same) {{
|
||||||
|
Write-Host " $Name unchanged ($view)"
|
||||||
|
}}
|
||||||
|
else {{
|
||||||
|
$key.SetValue($Name, [string[]] $Value, [Microsoft.Win32.RegistryValueKind]::MultiString)
|
||||||
|
Write-Host " $Name written ($view)"
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
finally {{ $key.Dispose() }}
|
||||||
|
}}
|
||||||
|
finally {{ $base.Dispose() }}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
|
Write-Host "== ShopDB endpoint agent install ==" -ForegroundColor Cyan
|
||||||
Write-Host "BaseUrl : $BaseUrl"
|
Write-Host "BaseUrl : $BaseUrl"
|
||||||
Write-Host "InstallDir : $InstallDir"
|
Write-Host "InstallDir : $InstallDir"
|
||||||
Write-Host "ServiceName : $ServiceName"
|
Write-Host "ServiceName : $ServiceName"
|
||||||
|
Write-Host "Shared key : $sharedKeyMode"
|
||||||
|
|
||||||
|
# Per-PC credential lives under ProgramData\\ShopDB\\EndpointAgent\\credential and
|
||||||
|
# belongs to the agent. This installer never writes or deletes it, so a reinstall
|
||||||
|
# or upgrade keeps an enrolled PC enrolled.
|
||||||
|
$credentialDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent\\credential"
|
||||||
|
if (Test-Path (Join-Path $credentialDir "credential.bin")) {{
|
||||||
|
Write-Host " existing enrolled credential found; kept as is."
|
||||||
|
}}
|
||||||
|
|
||||||
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
|
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
|
||||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
@@ -306,22 +451,49 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
|
|||||||
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
||||||
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
|
||||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||||
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
{keywrite}
|
||||||
|
# Server public-key pins (current, plus next during a planned rotation). An
|
||||||
|
# empty list leaves any ServerPins already on the PC untouched.
|
||||||
|
$pinList = @($ServerPins | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }})
|
||||||
|
if ($pinList.Count -gt 0) {{
|
||||||
|
Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins" -Value $pinList
|
||||||
|
}}
|
||||||
|
else {{
|
||||||
|
Write-Warning "no server pin in this package; ServerPins left as is."
|
||||||
|
}}
|
||||||
|
|
||||||
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
|
# 1a. Trust the bundled server certificate, if one shipped. The agent's outbound
|
||||||
# TLS to a self-signed or internal-CA server fails closed until the machine
|
# TLS to a self-signed or internal-CA server fails closed until the machine
|
||||||
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
|
# trusts the cert. Importing into LocalMachine\\Root covers the SYSTEM service.
|
||||||
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
|
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
|
||||||
# certificate, in which case no cert is bundled.
|
# certificate, in which case no cert is bundled.
|
||||||
|
# Kept for agents older than 1.20 and PowerShell download paths that do not pin.
|
||||||
|
# Only a leaf is trusted: a CA in Root would trust everything it signs.
|
||||||
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
|
||||||
$certPath = Join-Path $sourceDir $CertFileName
|
$certPath = Join-Path $sourceDir $CertFileName
|
||||||
if (Test-Path $certPath) {{
|
if (Test-Path $certPath) {{
|
||||||
|
$certIsCa = -not $trustCertInRoot
|
||||||
|
if (-not $certIsCa) {{
|
||||||
|
try {{
|
||||||
|
$probe = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certPath)
|
||||||
|
foreach ($ext in $probe.Extensions) {{
|
||||||
|
if ($ext -is [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension] -and
|
||||||
|
$ext.CertificateAuthority) {{ $certIsCa = $true }}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
catch {{ }}
|
||||||
|
}}
|
||||||
|
if ($certIsCa) {{
|
||||||
|
Write-Warning "server certificate $CertFileName is a CA certificate; not importing it into LocalMachine\\Root."
|
||||||
|
}}
|
||||||
|
else {{
|
||||||
Write-Host " trusting bundled server certificate $CertFileName ..."
|
Write-Host " trusting bundled server certificate $CertFileName ..."
|
||||||
try {{
|
try {{
|
||||||
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
|
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
|
||||||
}}
|
}}
|
||||||
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
|
catch {{ Write-Warning "could not import server certificate: $($_.Exception.Message)" }}
|
||||||
}}
|
}}
|
||||||
|
}}
|
||||||
else {{
|
else {{
|
||||||
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
|
Write-Warning "server certificate $CertFileName named but not found beside install.ps1."
|
||||||
}}
|
}}
|
||||||
@@ -578,7 +750,7 @@ if (-not $serviceOk) {{
|
|||||||
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
|
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
|
||||||
}}
|
}}
|
||||||
Write-Host " service is running." -ForegroundColor Green
|
Write-Host " service is running." -ForegroundColor Green
|
||||||
|
{enrollnotice}
|
||||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||||
# Report only; never relaunch from here. This block used to Start-Process the
|
# Report only; never relaunch from here. This block used to Start-Process the
|
||||||
@@ -601,8 +773,8 @@ if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
|||||||
|
|
||||||
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
||||||
# ProgramData so the uninstaller and appsettings/cert are available later, then
|
# ProgramData so the uninstaller and appsettings/cert are available later, then
|
||||||
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
|
# scrub install.ps1 from wherever it was run. A shared-key build carries the live
|
||||||
# collector key in its default parameter, so it must not linger on the endpoint.
|
# key in its default parameter, so it must not linger on the endpoint.
|
||||||
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
|
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
|
||||||
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
|
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
|
||||||
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
|
||||||
@@ -618,8 +790,8 @@ $uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
|
|||||||
|
|
||||||
$installSelf = Join-Path $sourceDir "install.ps1"
|
$installSelf = Join-Path $sourceDir "install.ps1"
|
||||||
if (Test-Path $installSelf) {{
|
if (Test-Path $installSelf) {{
|
||||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
|
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1." }}
|
||||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
|
catch {{ Write-Warning "could not remove install.ps1 - delete it manually: $($_.Exception.Message)" }}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||||
@@ -639,7 +811,7 @@ try {{
|
|||||||
$ledgerTrayVersion = "not-installed"
|
$ledgerTrayVersion = "not-installed"
|
||||||
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
|
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
|
||||||
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
|
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
|
||||||
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
|
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}} sharedkey={{7}} serverpins={{8}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState, $sharedKeyMode, $pinList.Count
|
||||||
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
|
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
|
||||||
Write-Host "Install ledger: $ledgerPath"
|
Write-Host "Install ledger: $ledgerPath"
|
||||||
Write-Host " $ledgerLine" -ForegroundColor DarkGray
|
Write-Host " $ledgerLine" -ForegroundColor DarkGray
|
||||||
@@ -655,7 +827,13 @@ else {{
|
|||||||
}}
|
}}
|
||||||
""".format(
|
""".format(
|
||||||
baseurl=baseurl_literal,
|
baseurl=baseurl_literal,
|
||||||
collectorkey=collectorkey_literal,
|
keyparam=keyparam,
|
||||||
|
keycheck=keycheck,
|
||||||
|
keywrite=keywrite,
|
||||||
|
sharedkeymode=sharedkeymode,
|
||||||
|
enrollnotice=enrollnotice,
|
||||||
|
serverpins=pins_literal,
|
||||||
|
trustcert='$true' if trustcertinroot else '$false',
|
||||||
certfile=certfile_literal,
|
certfile=certfile_literal,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -672,6 +850,8 @@ def _siteagent_status_payload():
|
|||||||
'agentstaged': agent_staged,
|
'agentstaged': agent_staged,
|
||||||
'traystaged': tray_staged,
|
'traystaged': tray_staged,
|
||||||
'certavailable': _server_cert_path() is not None,
|
'certavailable': _server_cert_path() is not None,
|
||||||
|
'serverpin': _current_server_pin(),
|
||||||
|
'sharedkeyallowed': shared_key_allowed(),
|
||||||
'agentcurrent': agentstore.current_version('agent'),
|
'agentcurrent': agentstore.current_version('agent'),
|
||||||
'traycurrent': agentstore.current_version('tray'),
|
'traycurrent': agentstore.current_version('tray'),
|
||||||
'agentversions': agentstore.list_versions('agent'),
|
'agentversions': agentstore.list_versions('agent'),
|
||||||
@@ -694,7 +874,12 @@ def package_siteagent():
|
|||||||
"""Bundle the site agent as a downloadable ZIP.
|
"""Bundle the site agent as a downloadable ZIP.
|
||||||
|
|
||||||
Accepts either JSON or multipart/form-data with fields baseurl,
|
Accepts either JSON or multipart/form-data with fields baseurl,
|
||||||
collectorkey, cadenceseconds, and bundlecert. A multipart request may also
|
collectorkey, cadenceseconds, bundlecert, includesharedkey, and
|
||||||
|
nextserverpin. includesharedkey defaults to the site setting
|
||||||
|
plugin.endpoints.sharedkeyallowed; when off, collectorkey is ignored and
|
||||||
|
nothing in the package carries it. nextserverpin (64 lowercase hex) is
|
||||||
|
shipped beside the current server pin for a planned certificate rotation.
|
||||||
|
A multipart request may also
|
||||||
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
|
carry a certfile upload: an operator-supplied .crt/.pem that is validated as
|
||||||
X.509 and bundled so the installer trusts it as a root on the endpoint. This
|
X.509 and bundled so the installer trusts it as a root on the endpoint. This
|
||||||
is the reverse-proxy path where the app cannot see its own TLS cert. When no
|
is the reverse-proxy path where the app cannot see its own TLS cert. When no
|
||||||
@@ -717,11 +902,25 @@ def package_siteagent():
|
|||||||
collectorkey = (source.get('collectorkey') or '').strip()
|
collectorkey = (source.get('collectorkey') or '').strip()
|
||||||
cadenceseconds = source.get('cadenceseconds', 900)
|
cadenceseconds = source.get('cadenceseconds', 900)
|
||||||
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
|
bundlecert = str(source.get('bundlecert', '')).lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
rawinclude = source.get('includesharedkey')
|
||||||
|
if rawinclude is None or str(rawinclude).strip() == '':
|
||||||
|
includesharedkey = shared_key_allowed()
|
||||||
|
else:
|
||||||
|
includesharedkey = str(rawinclude).lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
nextserverpin = (source.get('nextserverpin') or '').strip()
|
||||||
|
|
||||||
if not baseurl or not collectorkey:
|
if not includesharedkey:
|
||||||
|
collectorkey = ''
|
||||||
|
if not baseurl or (includesharedkey and not collectorkey):
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
'baseurl and collectorkey are required',
|
'baseurl and collectorkey are required' if includesharedkey
|
||||||
|
else 'baseurl is required',
|
||||||
|
http_code=400)
|
||||||
|
if nextserverpin and not _PIN_PATTERN.match(nextserverpin):
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'nextserverpin must be 64 lowercase hex characters',
|
||||||
http_code=400)
|
http_code=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -739,6 +938,8 @@ def package_siteagent():
|
|||||||
|
|
||||||
appsettings = dict(_BASE_APPSETTINGS)
|
appsettings = dict(_BASE_APPSETTINGS)
|
||||||
appsettings['cadenceseconds'] = cadenceseconds
|
appsettings['cadenceseconds'] = cadenceseconds
|
||||||
|
if not includesharedkey:
|
||||||
|
appsettings['apikey'] = ''
|
||||||
|
|
||||||
# An operator-uploaded cert wins over the auto-detected server cert: it is
|
# An operator-uploaded cert wins over the auto-detected server cert: it is
|
||||||
# the explicit reverse-proxy answer where the app cannot see its own TLS
|
# the explicit reverse-proxy answer where the app cannot see its own TLS
|
||||||
@@ -758,14 +959,43 @@ def package_siteagent():
|
|||||||
|
|
||||||
certfilename = 'server.crt' if (cert_pem or certpath) else None
|
certfilename = 'server.crt' if (cert_pem or certpath) else None
|
||||||
|
|
||||||
|
# Pin the bundled leaf when there is one; a CA bundle cannot be pinned (the
|
||||||
|
# agent hashes the leaf) so fall back to this server's own certificate.
|
||||||
|
trustcertinroot = True
|
||||||
|
currentpin = None
|
||||||
|
if certfilename:
|
||||||
|
if cert_pem:
|
||||||
|
bundledbytes = cert_pem.encode('ascii')
|
||||||
|
else:
|
||||||
|
with open(certpath, 'rb') as handle:
|
||||||
|
bundledbytes = handle.read()
|
||||||
|
bundledpin, bundledisca = _leaf_certificate_facts(bundledbytes)
|
||||||
|
trustcertinroot = not bundledisca
|
||||||
|
if not bundledisca:
|
||||||
|
currentpin = bundledpin
|
||||||
|
if currentpin is None:
|
||||||
|
currentpin = _current_server_pin()
|
||||||
|
if nextserverpin and currentpin is None:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'nextserverpin needs a current server pin; bundle or upload the '
|
||||||
|
'server leaf certificate',
|
||||||
|
http_code=400)
|
||||||
|
serverpins = [pin for pin in (currentpin, nextserverpin) if pin]
|
||||||
|
serverpins = list(dict.fromkeys(serverpins))
|
||||||
|
appsettings['serverpins'] = serverpins
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||||
archive.writestr('appsettings.json',
|
archive.writestr('appsettings.json',
|
||||||
json.dumps(appsettings, indent=2) + '\n')
|
json.dumps(appsettings, indent=2) + '\n')
|
||||||
archive.writestr('install.ps1',
|
archive.writestr('install.ps1',
|
||||||
_install_script(baseurl.rstrip('/'), collectorkey,
|
_install_script(baseurl.rstrip('/'), collectorkey,
|
||||||
cadenceseconds, certfilename))
|
cadenceseconds, certfilename,
|
||||||
archive.writestr('README.txt', _README)
|
serverpins=serverpins,
|
||||||
|
includesharedkey=includesharedkey,
|
||||||
|
trustcertinroot=trustcertinroot))
|
||||||
|
archive.writestr('README.txt', _readme(includesharedkey))
|
||||||
|
|
||||||
if os.path.isfile(UNINSTALLER_PATH):
|
if os.path.isfile(UNINSTALLER_PATH):
|
||||||
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ public static class ShopDbServerPinValidator
|
|||||||
return Pins.Contains(LastPresented);
|
return Pins.Contains(LastPresented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsPinned(string pin)
|
||||||
|
{
|
||||||
|
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
|
||||||
|
}
|
||||||
|
|
||||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||||
public static string SpkiSha256Hex(byte[] der)
|
public static string SpkiSha256Hex(byte[] der)
|
||||||
@@ -264,8 +269,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
|
|||||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||||
}
|
}
|
||||||
$presented = ''
|
$presented = ''
|
||||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
$matched = $false
|
||||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
if ('ShopDbServerPinValidator' -as [type]) {
|
||||||
|
$presented = [string][ShopDbServerPinValidator]::LastPresented
|
||||||
|
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
|
||||||
|
}
|
||||||
|
# Innermost exception text: the outer WebException hides the real cause
|
||||||
|
# (unexpected EOF, connection reset, trust failure).
|
||||||
|
$message = ''
|
||||||
|
$exception = $ErrorRecord.Exception
|
||||||
|
while ($exception) {
|
||||||
|
if ($exception.Message) { $message = $exception.Message }
|
||||||
|
$exception = $exception.InnerException
|
||||||
|
}
|
||||||
|
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-ShopDbAuthFailure($Failure) {
|
function Write-ShopDbAuthFailure($Failure) {
|
||||||
@@ -285,10 +302,17 @@ function Write-ShopDbAuthFailure($Failure) {
|
|||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||||
|
# A pin was seen, but only an absent pin is a pin failure. A
|
||||||
|
# matched pin means the handshake passed and something later
|
||||||
|
# failed (server dropped mid-transfer, timeout).
|
||||||
|
if ($Failure.PinMatched) {
|
||||||
|
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
|
||||||
|
} else {
|
||||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
|
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
|
||||||
|
|||||||
194
plugins/endpoints/client/Install-ShopDBServerCertificate.ps1
Normal file
194
plugins/endpoints/client/Install-ShopDBServerCertificate.ps1
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Trust the ShopDB server's own certificate on this PC by adding it to
|
||||||
|
LocalMachine\Root, after checking it is the expected certificate and not a
|
||||||
|
certificate authority.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
WHY: endpoint agents older than 1.20 check the normal Windows certificate
|
||||||
|
chain when they download the upgrade package. They know nothing about
|
||||||
|
ServerPins. A server with a self-signed certificate is therefore refused
|
||||||
|
by those agents, and the upgrade to 1.20 never lands. The certificate has
|
||||||
|
to be trusted on the PC BEFORE a store wave queues the upgrade.
|
||||||
|
|
||||||
|
PCs installed by the server's generated installer already trust it. PCs
|
||||||
|
installed from the share do not, and need this entry.
|
||||||
|
|
||||||
|
Adding a non-CA leaf certificate to Root trusts exactly that one server.
|
||||||
|
It cannot be used to vouch for any other site, so the blast radius is the
|
||||||
|
server itself. A CA certificate in Root would trust everything that CA
|
||||||
|
signs, which is why a CA is refused unless -AllowCa is passed.
|
||||||
|
|
||||||
|
Get the .cer from Settings > Endpoints > Server certificate (download), or
|
||||||
|
GET /api/endpoints/settings/server-cert/download. The expected thumbprint
|
||||||
|
is the sha1thumbprint field of GET /api/endpoints/settings/server-cert.
|
||||||
|
|
||||||
|
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, in the same
|
||||||
|
targeting as the Set-ShopDBServerPins.ps1 entry. Idempotent: the Root store
|
||||||
|
is checked first and nothing is written when the certificate is already
|
||||||
|
there, so it is safe every cycle. Nothing else is removed or changed.
|
||||||
|
|
||||||
|
.PARAMETER CertPath
|
||||||
|
Path to the server certificate file, DER or PEM (.cer / .crt / .pem).
|
||||||
|
|
||||||
|
.PARAMETER Thumbprint
|
||||||
|
Expected SHA-1 thumbprint of the certificate. Spaces, colons and case are
|
||||||
|
ignored. The file is refused when it does not match.
|
||||||
|
|
||||||
|
.PARAMETER AllowCa
|
||||||
|
Import even when the certificate is a CA (basicConstraints CA=true). Only
|
||||||
|
for a site that deliberately trusts its own private CA.
|
||||||
|
|
||||||
|
.PARAMETER LogPath
|
||||||
|
Append a transcript here as well as writing to the pipeline.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Install-ShopDBServerCertificate.ps1 -CertPath <share>\shopdb-server.cer -Thumbprint <sha1thumbprint>
|
||||||
|
Trust the server certificate on this PC.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Install-ShopDBServerCertificate.ps1 -CertPath .\shopdb-server.cer -Thumbprint <sha1thumbprint> -WhatIf
|
||||||
|
Show what would be imported without touching the store.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Exit codes:
|
||||||
|
0 imported, or already trusted
|
||||||
|
1 bad input, mismatch, refused CA, or an error stopped the import
|
||||||
|
#>
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$CertPath,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Thumbprint,
|
||||||
|
[switch]$AllowCa,
|
||||||
|
[string]$LogPath
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Log {
|
||||||
|
param([string]$Message)
|
||||||
|
$line = "{0} [Install-ShopDBServerCertificate] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||||
|
Write-Output $line
|
||||||
|
if ($LogPath) {
|
||||||
|
try { Add-Content -LiteralPath $LogPath -Value $line -WhatIf:$false -ErrorAction Stop } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-Certificate {
|
||||||
|
param([string]$Path)
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||||
|
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
|
||||||
|
$match = [regex]::Match($text, '-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----',
|
||||||
|
[System.Text.RegularExpressions.RegexOptions]::Singleline)
|
||||||
|
if ($match.Success) {
|
||||||
|
$bytes = [Convert]::FromBase64String(($match.Groups[1].Value -replace '\s', ''))
|
||||||
|
}
|
||||||
|
return New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,[byte[]]$bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 1. validate the request ------------------------------------------------
|
||||||
|
|
||||||
|
$expected = ($Thumbprint -replace '[\s:]', '').ToUpperInvariant()
|
||||||
|
if ($expected -notmatch '^[0-9A-F]{40}$') {
|
||||||
|
Log "ERROR '$Thumbprint' is not a SHA-1 thumbprint (40 hex characters); nothing imported"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $CertPath -PathType Leaf)) {
|
||||||
|
Log "ERROR certificate file not found: $CertPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cert = Read-Certificate -Path $CertPath
|
||||||
|
} catch {
|
||||||
|
Log "ERROR could not read a certificate from $CertPath : $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$actual = $cert.Thumbprint.ToUpperInvariant()
|
||||||
|
Log "file: $CertPath"
|
||||||
|
Log "subject: $($cert.Subject); issuer: $($cert.Issuer); expires: $($cert.NotAfter.ToString('yyyy-MM-dd'))"
|
||||||
|
if ($actual -ne $expected) {
|
||||||
|
Log "ERROR thumbprint of the file is $actual, expected $expected; refusing to import"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cert.HasPrivateKey) {
|
||||||
|
Log 'ERROR the file carries a private key; export the public certificate only'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 2. refuse a CA ---------------------------------------------------------
|
||||||
|
|
||||||
|
$isCa = $false
|
||||||
|
foreach ($extension in $cert.Extensions) {
|
||||||
|
if ($extension.Oid.Value -eq '2.5.29.19') {
|
||||||
|
$constraints = [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]$extension
|
||||||
|
$isCa = $constraints.CertificateAuthority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($isCa) {
|
||||||
|
if (-not $AllowCa) {
|
||||||
|
Log ('ERROR the certificate is a CA (basicConstraints CA=true). In Root it would trust every ' +
|
||||||
|
'certificate it signs, not just the ShopDB server. Refusing; pass -AllowCa only if that is intended.')
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Log 'WARN the certificate is a CA and -AllowCa was passed; everything it signs will be trusted'
|
||||||
|
} else {
|
||||||
|
Log 'not a CA (basicConstraints CA absent or false); trusts only this server'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cert.Subject -ne $cert.Issuer) {
|
||||||
|
Log 'WARN the certificate is not self-signed; Windows may still need its issuer to build the chain'
|
||||||
|
}
|
||||||
|
if ($cert.NotAfter -lt (Get-Date)) {
|
||||||
|
Log 'WARN the certificate has expired; the server needs a new one, importing anyway'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 3. import into LocalMachine\Root ---------------------------------------
|
||||||
|
|
||||||
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
|
[System.Security.Cryptography.X509Certificates.StoreName]::Root,
|
||||||
|
[System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
|
||||||
|
try {
|
||||||
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
|
||||||
|
try {
|
||||||
|
$present = $store.Certificates.Find(
|
||||||
|
[System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false)
|
||||||
|
$alreadyTrusted = $present.Count -gt 0
|
||||||
|
} finally { $store.Close() }
|
||||||
|
|
||||||
|
if ($alreadyTrusted) {
|
||||||
|
Log "LocalMachine\Root already holds $actual; nothing to do"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess("Cert:\LocalMachine\Root", "Import $actual ($($cert.Subject))")) {
|
||||||
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
|
||||||
|
try { $store.Add($cert) } finally { $store.Close() }
|
||||||
|
|
||||||
|
# Read back rather than trust Add.
|
||||||
|
$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly)
|
||||||
|
try {
|
||||||
|
$after = $store.Certificates.Find(
|
||||||
|
[System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint, $actual, $false)
|
||||||
|
} finally { $store.Close() }
|
||||||
|
if ($after.Count -eq 0) {
|
||||||
|
Log "ERROR $actual did not read back from LocalMachine\Root after import"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Log "imported $actual into LocalMachine\Root"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Log "ERROR $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
$store.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -76,6 +76,8 @@ Then widen by hostname list (or machine number list) in batches:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The script compares before it writes, so `Always` costs nothing per cycle.
|
The script compares before it writes, so `Always` costs nothing per cycle.
|
||||||
|
A running agent picks up a changed `ServerPins` within about 60 seconds; no
|
||||||
|
service restart is needed.
|
||||||
|
|
||||||
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
|
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
|
||||||
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
|
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
|
||||||
@@ -83,9 +85,56 @@ equal to the pin on the settings page, and the next `report-asset-*.log` under
|
|||||||
`C:\Logs\Shopfloor` says `server pin check on (1 pin(s))` followed by a
|
`C:\Logs\Shopfloor` says `server pin check on (1 pin(s))` followed by a
|
||||||
RESPONSE line.
|
RESPONSE line.
|
||||||
|
|
||||||
**Rotating the server key later:** put the old AND the new pin in `-Pins`
|
**Rotating the server key later:** any server-side change that gives the
|
||||||
(`"<oldpin>,<newpin>"`), wait until every PC has both, switch the certificate,
|
certificate a new key (a regenerated or re-issued certificate) changes the pin.
|
||||||
then drop the old pin.
|
Add the NEXT pin everywhere first: put the old AND the new pin in `-Pins`
|
||||||
|
(`"<oldpin>,<newpin>"`), wait until every PC has both, only then switch the
|
||||||
|
certificate on the server, then drop the old pin. Switching the server first
|
||||||
|
cuts off every pinned PC, including its ability to receive the fixed pin list
|
||||||
|
through anything that talks to ShopDB. A renewal that keeps the same key keeps
|
||||||
|
the same pin. PCs trusted through the Root store (below) also need the new
|
||||||
|
certificate imported before the switch.
|
||||||
|
|
||||||
|
### Trust the server certificate on PCs with agents older than 1.20
|
||||||
|
|
||||||
|
Required before ANY store wave. Agents older than 1.20 know nothing about
|
||||||
|
`ServerPins`: when they download the upgrade package they check the normal
|
||||||
|
Windows certificate chain. With a self-signed server certificate that check
|
||||||
|
fails and the upgrade never lands.
|
||||||
|
|
||||||
|
- PCs installed by the server's generated installer (Build site agent) already
|
||||||
|
trust the certificate.
|
||||||
|
- PCs installed from the share do NOT, and need this entry.
|
||||||
|
|
||||||
|
Get the certificate and its thumbprint from the server:
|
||||||
|
|
||||||
|
- Settings -> Endpoints -> Server Certificate -> download, or
|
||||||
|
`GET /api/endpoints/settings/server-cert/download` (a `.cer`).
|
||||||
|
- The thumbprint is `sha1thumbprint` on `GET /api/endpoints/settings/server-cert`.
|
||||||
|
|
||||||
|
Stage the `.cer` and `plugins/endpoints/client/Install-ShopDBServerCertificate.ps1`
|
||||||
|
on the share and add a PS1 entry with the SAME targeting as the pin entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_comment": "Trust the ShopDB server certificate for pre-1.20 agent upgrades.",
|
||||||
|
"Name": "ShopDB server certificate",
|
||||||
|
"Type": "PS1",
|
||||||
|
"Script": "scripts/Install-ShopDBServerCertificate.ps1",
|
||||||
|
"Args": "-CertPath <share>\\scripts\\shopdb-server.cer -Thumbprint <sha1thumbprint>",
|
||||||
|
"DetectionMethod": "Always",
|
||||||
|
"TargetMachineNumbers": ["<pilotbay>"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The script refuses a file whose thumbprint differs, refuses a CA certificate
|
||||||
|
(one in Root would trust everything it signs), imports into
|
||||||
|
`LocalMachine\Root` only when the certificate is not already there, and removes
|
||||||
|
nothing. A non-CA leaf in Root trusts only this one server.
|
||||||
|
|
||||||
|
If a wave reaches a PC that does not trust the certificate, the download fails
|
||||||
|
and the Migration tab marks the PC **Blocked** with `server certificate not
|
||||||
|
trusted by this PC`. Fix trust with the entry above, then queue the PC again.
|
||||||
|
|
||||||
## Phase 2: re-stamp the share's agent entries
|
## Phase 2: re-stamp the share's agent entries
|
||||||
|
|
||||||
@@ -158,7 +207,10 @@ Repeat until **Ready** is 0.
|
|||||||
`no serial on record` / `duplicate hostname`: fix the computer record.
|
`no serial on record` / `duplicate hostname`: fix the computer record.
|
||||||
`commands locked`: release the endpoint lock or policy hold. `no agent package
|
`commands locked`: release the endpoint lock or policy hold. `no agent package
|
||||||
in store`: upload 1.20. `agent too old for remote upgrade`: move it via the
|
in store`: upload 1.20. `agent too old for remote upgrade`: move it via the
|
||||||
share (targeted agent entry as in phase 2).
|
share (targeted agent entry as in phase 2). `server certificate not trusted
|
||||||
|
by this PC`: add the PC to the certificate entry (phase 1), wait a cycle,
|
||||||
|
queue it again. `reenroll blocked`: the PC was rolled back on purpose; clear
|
||||||
|
it with allow-reenroll (see Rollback) only when moving it forward again.
|
||||||
- **Not reporting:** the PC has no working agent. It can only move via the
|
- **Not reporting:** the PC has no working agent. It can only move via the
|
||||||
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
|
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
|
||||||
Pending or Enrolled once the agent runs.
|
Pending or Enrolled once the agent runs.
|
||||||
@@ -208,22 +260,49 @@ read it until this table is done.
|
|||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
|
The order matters. A 1.20 agent that is plainly un-enrolled enrolls again
|
||||||
|
within about a second, before any downgrade can land, and the old agent it is
|
||||||
|
then replaced with only knows the shared key, which the server refuses for an
|
||||||
|
enrolled PC. Block re-enrollment FIRST.
|
||||||
|
|
||||||
**One PC:**
|
**One PC:**
|
||||||
|
|
||||||
1. Make sure **Shared key allowed** is on.
|
1. Make sure **Shared key allowed** is on.
|
||||||
2. Credentials tab -> **Un-enroll** the PC. Its credential stops working
|
2. Credentials tab -> **Un-enroll** the PC with **Keep this PC from enrolling
|
||||||
immediately; the server admits the shared key for it again.
|
again** ticked. API equivalent:
|
||||||
3. Push the previous agent: Upload New/Legacy Agents -> make the legacy build
|
`POST /api/endpoints/enrollment/assets/<assetid>/revoke`
|
||||||
current and upgrade that PC, or re-stamp its share entry to the old hash.
|
`{"blockreenroll": true, "reason": "rollback"}`.
|
||||||
4. Remove the PC from the pin entry's targets only if pinning itself is the
|
Its credential stops working immediately, the server admits the shared key
|
||||||
|
for it again, and its enroll attempts stay pending with `reenroll blocked`.
|
||||||
|
3. Make the previous agent current in the store (Upload New/Legacy Agents) and
|
||||||
|
queue the upgrade for that PC, or re-stamp its share entry to the old hash.
|
||||||
|
4. Verify: the Fleet row reports the old agent version, and the reporter logs
|
||||||
|
show `RESPONSE (shared key)`.
|
||||||
|
5. Remove the PC from the pin entry's targets only if pinning itself is the
|
||||||
problem (`Set-ShopDBServerPins.ps1 -Clear` removes the value).
|
problem (`Set-ShopDBServerPins.ps1 -Clear` removes the value).
|
||||||
|
6. Later, when moving the PC forward again, lift the block:
|
||||||
A 1.20 agent that is un-enrolled but NOT downgraded simply enrolls again on its
|
`POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the
|
||||||
next cycle; that is expected.
|
same action in the UI). The PC enrolls on its next attempt once it runs
|
||||||
|
1.20. Until then the Migration tab shows it blocked with `reenroll blocked`.
|
||||||
|
|
||||||
**Whole fleet:** turn **Shared key allowed** back on first. Every PC that still
|
**Whole fleet:** turn **Shared key allowed** back on first. Every PC that still
|
||||||
holds the shared key reports again immediately. Then un-enroll and downgrade
|
holds the shared key reports again immediately. Then revoke with the block and
|
||||||
PCs as above, in waves.
|
downgrade PCs as above, in waves.
|
||||||
|
|
||||||
|
## Verified on Windows
|
||||||
|
|
||||||
|
A full pass on a Windows test PC against a test server proved:
|
||||||
|
|
||||||
|
- the agent enrolls on its own;
|
||||||
|
- the shared key is refused for the enrolled PC (`SHARED_KEY_REFUSED`);
|
||||||
|
- a request naming another asset is refused (`ASSET_MISMATCH`);
|
||||||
|
- server pins are enforced;
|
||||||
|
- the tray pipe works for a standard (non-admin) user;
|
||||||
|
- alert acknowledge works;
|
||||||
|
- both SYSTEM reporters send with the per-PC credential;
|
||||||
|
- revoke is followed by re-enrollment;
|
||||||
|
- rollback to the previous agent works;
|
||||||
|
- with the shared key switched off, enrolled PCs keep working.
|
||||||
|
|
||||||
## Quick reference
|
## Quick reference
|
||||||
|
|
||||||
|
|||||||
@@ -222,10 +222,11 @@
|
|||||||
<div class="card form-card">
|
<div class="card form-card">
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
Produce an instance-locked agent package stamped with this server's
|
Produce an instance-locked agent package stamped with this server's
|
||||||
URL, the endpoint agent key, and the reporting cadence, so it deploys
|
URL, server pin, optionally the endpoint agent key, and the reporting
|
||||||
with no manual configuration on the client. The download includes the
|
cadence, so it deploys with no manual configuration on the client. The
|
||||||
agent, a non-secret <code>appsettings.json</code>, and an
|
download includes the agent, a non-secret
|
||||||
<code>install.ps1</code> that writes the credentials to the registry
|
<code>appsettings.json</code>, and an <code>install.ps1</code> that
|
||||||
|
writes the settings to the registry
|
||||||
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
|
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
|
||||||
appsettings.
|
appsettings.
|
||||||
</p>
|
</p>
|
||||||
@@ -245,6 +246,16 @@
|
|||||||
placeholder="https://shopdb.example"
|
placeholder="https://shopdb.example"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="agentincludesharedkey" type="checkbox" />
|
||||||
|
<span>
|
||||||
|
Include shared key. When off, the package holds no key and each PC
|
||||||
|
requests enrollment for an admin to approve in Settings >
|
||||||
|
Endpoints > Enrollment.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-if="agentincludesharedkey">
|
||||||
<label class="field-label" for="agent-key">Endpoint agent key</label>
|
<label class="field-label" for="agent-key">Endpoint agent key</label>
|
||||||
<input
|
<input
|
||||||
id="agent-key"
|
id="agent-key"
|
||||||
@@ -253,6 +264,28 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Paste the endpoint agent key generated above"
|
placeholder="Paste the endpoint agent key generated above"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<label class="field-label" for="agent-nextpin">
|
||||||
|
Next server pin (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="agent-nextpin"
|
||||||
|
v-model.trim="agentnextserverpin"
|
||||||
|
class="form-control mono"
|
||||||
|
type="text"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="64 lowercase hex characters"
|
||||||
|
/>
|
||||||
|
<p class="field-hint">
|
||||||
|
Current server pin:
|
||||||
|
<code>{{ agentserverpin || 'not available (no server certificate found)' }}</code>.
|
||||||
|
Add the next certificate's pin only ahead of a planned certificate
|
||||||
|
rotation.
|
||||||
|
</p>
|
||||||
|
<p v-if="agentnextpininvalid()" class="field-error">
|
||||||
|
The next server pin must be exactly 64 lowercase hex characters.
|
||||||
|
</p>
|
||||||
|
|
||||||
<label class="field-label" for="agent-cadence">
|
<label class="field-label" for="agent-cadence">
|
||||||
Reporting cadence (seconds)
|
Reporting cadence (seconds)
|
||||||
@@ -287,9 +320,10 @@
|
|||||||
/>
|
/>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
For a reverse-proxy or internal-CA server this app cannot see, upload
|
For a reverse-proxy or internal-CA server this app cannot see, upload
|
||||||
the server's public certificate (or issuing CA) in PEM format. It is
|
the server's public certificate in PEM format. It is validated,
|
||||||
validated, bundled with the package, and imported into the endpoint's
|
bundled with the package, pinned, and imported into the endpoint's
|
||||||
Trusted Root store by <code>install.ps1</code>. Leave empty when the
|
Trusted Root store by <code>install.ps1</code> (a CA certificate is
|
||||||
|
bundled but never imported or pinned). Leave empty when the
|
||||||
server uses a publicly trusted certificate. An upload takes precedence
|
server uses a publicly trusted certificate. An upload takes precedence
|
||||||
over the bundled server certificate above.
|
over the bundled server certificate above.
|
||||||
</p>
|
</p>
|
||||||
@@ -298,7 +332,9 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
|
:disabled="agentbusy || !agentbaseurl
|
||||||
|
|| (agentincludesharedkey && !agentcollectorkey)
|
||||||
|
|| agentnextpininvalid()"
|
||||||
@click="buildSiteAgent"
|
@click="buildSiteAgent"
|
||||||
>
|
>
|
||||||
{{ agentbusy ? 'Building...' : 'Build site agent' }}
|
{{ agentbusy ? 'Building...' : 'Build site agent' }}
|
||||||
@@ -529,6 +565,14 @@ const agentcertfile = ref(null)
|
|||||||
const agentbusy = ref(false)
|
const agentbusy = ref(false)
|
||||||
const agentmessage = ref('')
|
const agentmessage = ref('')
|
||||||
const agenterror = ref('')
|
const agenterror = ref('')
|
||||||
|
const agentincludesharedkey = ref(true)
|
||||||
|
const agentnextserverpin = ref('')
|
||||||
|
const agentserverpin = ref('')
|
||||||
|
|
||||||
|
function agentnextpininvalid() {
|
||||||
|
const pin = agentnextserverpin.value
|
||||||
|
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||||
|
}
|
||||||
|
|
||||||
// Versioned agent/tray binaries. Each block owns its own upload form, retained
|
// Versioned agent/tray binaries. Each block owns its own upload form, retained
|
||||||
// version list, and busy/message state so the two rows act independently. The
|
// version list, and busy/message state so the two rows act independently. The
|
||||||
@@ -594,6 +638,8 @@ onMounted(async () => {
|
|||||||
agentstaged.value = !!status.agentstaged
|
agentstaged.value = !!status.agentstaged
|
||||||
certavailable.value = !!status.certavailable
|
certavailable.value = !!status.certavailable
|
||||||
agentbundlecert.value = !!status.certavailable
|
agentbundlecert.value = !!status.certavailable
|
||||||
|
agentincludesharedkey.value = status.sharedkeyallowed !== false
|
||||||
|
agentserverpin.value = status.serverpin || ''
|
||||||
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
|
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
|
||||||
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
|
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
|
||||||
} catch (statusError) {
|
} catch (statusError) {
|
||||||
@@ -717,7 +763,13 @@ async function buildSiteAgent() {
|
|||||||
try {
|
try {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('baseurl', agentbaseurl.value)
|
form.append('baseurl', agentbaseurl.value)
|
||||||
|
if (agentincludesharedkey.value) {
|
||||||
form.append('collectorkey', agentcollectorkey.value)
|
form.append('collectorkey', agentcollectorkey.value)
|
||||||
|
}
|
||||||
|
form.append('includesharedkey', agentincludesharedkey.value ? 'true' : 'false')
|
||||||
|
if (agentnextserverpin.value) {
|
||||||
|
form.append('nextserverpin', agentnextserverpin.value)
|
||||||
|
}
|
||||||
form.append('cadenceseconds', agentcadenceseconds.value)
|
form.append('cadenceseconds', agentcadenceseconds.value)
|
||||||
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
|
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
|
||||||
if (agentcertfile.value) {
|
if (agentcertfile.value) {
|
||||||
|
|||||||
@@ -374,10 +374,18 @@
|
|||||||
<tr v-if="!credentialRows.length">
|
<tr v-if="!credentialRows.length">
|
||||||
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
|
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="row in credentialRows" :key="row.assetid">
|
<template v-for="row in credentialRows" :key="row.assetid">
|
||||||
|
<tr class="credential-row">
|
||||||
<td>{{ row.hostname || '-' }}</td>
|
<td>{{ row.hostname || '-' }}</td>
|
||||||
<td>{{ row.serialnumber || '-' }}</td>
|
<td>{{ row.serialnumber || '-' }}</td>
|
||||||
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
|
<td>
|
||||||
|
<span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="row.reenrollblocked"
|
||||||
|
class="state-chip blocked block-chip"
|
||||||
|
:title="row.reenrollblockedreason || ''"
|
||||||
|
>Re-enroll blocked</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
|
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
@@ -391,12 +399,58 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-danger btn-sm revoke"
|
class="btn btn-danger btn-sm revoke"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="revoke(row)"
|
@click="openRevoke(row)"
|
||||||
>
|
>
|
||||||
Un-enroll
|
Un-enroll
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="row.reenrollblocked"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-sm allow-reenroll"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="allowReenroll(row)"
|
||||||
|
>
|
||||||
|
Allow re-enrollment
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="revoking && revoking.assetid === row.assetid" class="revoke-row">
|
||||||
|
<td :colspan="isadmin ? 8 : 7">
|
||||||
|
<p class="field-hint">
|
||||||
|
Un-enroll {{ revoking.hostname }}? Its credential stops working
|
||||||
|
immediately. {{ revokeFallback }}
|
||||||
|
</p>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="revoking.block" type="checkbox" class="revoke-block" />
|
||||||
|
<span>
|
||||||
|
Keep this PC from enrolling again (use before rolling back to
|
||||||
|
an older agent, or for a compromised PC)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="approve-form">
|
||||||
|
<input
|
||||||
|
v-if="revoking.block"
|
||||||
|
v-model="revoking.reason"
|
||||||
|
type="text"
|
||||||
|
maxlength="64"
|
||||||
|
class="form-control revoke-reason"
|
||||||
|
placeholder="Reason (optional)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger btn-sm revoke-confirm"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="confirmRevoke"
|
||||||
|
>
|
||||||
|
Un-enroll
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="revoking = null">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -518,7 +572,9 @@ function waveConfirmText(count) {
|
|||||||
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
|
return `Upgrade ${count} PC(s) to endpoint agent ${target.value}?\n\n`
|
||||||
+ 'Each PC downloads the new agent and restarts its service. Once the new '
|
+ 'Each PC downloads the new agent and restarts its service. Once the new '
|
||||||
+ 'agent is running it enrolls automatically with the shared key it already '
|
+ 'agent is running it enrolls automatically with the shared key it already '
|
||||||
+ 'holds; no further action is needed unless it lands in Pending.'
|
+ 'holds; no further action is needed unless it lands in Pending.\n\n'
|
||||||
|
+ 'PCs on agents older than 1.20 must trust the server certificate before '
|
||||||
|
+ 'they can download the upgrade (see runbook).'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWave(body, count) {
|
async function startWave(body, count) {
|
||||||
@@ -729,15 +785,37 @@ async function reject(request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revoke(row) {
|
const revoking = ref(null)
|
||||||
const fallback = enrollmentSettings.value.sharedkeyallowed
|
|
||||||
|
const revokeFallback = computed(() => {
|
||||||
|
if (revoking.value?.block) {
|
||||||
|
return 'It stays un-enrolled and keeps reporting on the shared key while that is allowed.'
|
||||||
|
}
|
||||||
|
return enrollmentSettings.value.sharedkeyallowed
|
||||||
? 'It falls back to the shared key and enrolls again on its own.'
|
? 'It falls back to the shared key and enrolls again on its own.'
|
||||||
: 'The shared key is off, so it cannot report until an admin approves its new request.'
|
: 'The shared key is off, so it cannot report until an admin approves its new request.'
|
||||||
if (!confirm(`Un-enroll ${row.hostname}?\n\nIts credential stops working immediately. ${fallback}`)) return
|
})
|
||||||
|
|
||||||
|
function openRevoke(row) {
|
||||||
|
revoking.value = { assetid: row.assetid, hostname: row.hostname, block: false, reason: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevoke() {
|
||||||
|
const current = revoking.value
|
||||||
|
if (!current) return
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`)
|
const url = `/endpoints/enrollment/assets/${current.assetid}/revoke`
|
||||||
toast.success(`Un-enrolled ${row.hostname}`)
|
if (current.block) {
|
||||||
|
const body = { blockreenroll: true }
|
||||||
|
const reason = (current.reason || '').trim()
|
||||||
|
if (reason) body.reason = reason
|
||||||
|
await api.post(url, body)
|
||||||
|
} else {
|
||||||
|
await api.post(url)
|
||||||
|
}
|
||||||
|
toast.success(`Un-enrolled ${current.hostname}`)
|
||||||
|
revoking.value = null
|
||||||
await loadEnrollment({ quiet: true })
|
await loadEnrollment({ quiet: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(apiError(error, 'Could not un-enroll'))
|
toast.error(apiError(error, 'Could not un-enroll'))
|
||||||
@@ -746,6 +824,21 @@ async function revoke(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function allowReenroll(row) {
|
||||||
|
if (!confirm(`Allow ${row.hostname} to enroll again?\n\n`
|
||||||
|
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await api.post(`/endpoints/enrollment/assets/${row.assetid}/allow-reenroll`)
|
||||||
|
toast.success(`${row.hostname} may enroll again`)
|
||||||
|
await loadEnrollment({ quiet: true })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(apiError(error, 'Could not allow re-enrollment'))
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Shared -----------------------------------------------------------
|
// ---- Shared -----------------------------------------------------------
|
||||||
|
|
||||||
function stateLabel(state) {
|
function stateLabel(state) {
|
||||||
@@ -982,6 +1075,10 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.approve-form .form-control { max-width: 260px; }
|
.approve-form .form-control { max-width: 260px; }
|
||||||
|
|
||||||
|
.block-chip { margin-left: 0.35rem; }
|
||||||
|
|
||||||
|
.revoke-row .form-control { max-width: 260px; }
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ function enrollmentPayload() {
|
|||||||
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
|
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
|
||||||
computers: [
|
computers: [
|
||||||
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
|
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
|
||||||
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto' }
|
credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto',
|
||||||
|
reenrollblocked: false, reenrollblockedreason: null },
|
||||||
|
{ assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked',
|
||||||
|
credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto',
|
||||||
|
reenrollblocked: true, reenrollblockedreason: 'rollback' }
|
||||||
],
|
],
|
||||||
requests: [
|
requests: [
|
||||||
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
|
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
|
||||||
@@ -125,6 +129,8 @@ describe('EnrollmentSettings', () => {
|
|||||||
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
||||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/)
|
expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/)
|
||||||
expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/)
|
expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/)
|
||||||
|
expect(confirmSpy.mock.calls[0][0]).toMatch(
|
||||||
|
/older than 1\.20 must trust the server certificate before they can download the upgrade/)
|
||||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
|
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,14 +172,46 @@ describe('EnrollmentSettings', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('un-enrolls through the revoke route', async () => {
|
it('un-enrolls through the revoke route', async () => {
|
||||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
|
||||||
const wrapper = await render()
|
const wrapper = await render()
|
||||||
await openCredentials(wrapper)
|
await openCredentials(wrapper)
|
||||||
await wrapper.find('.revoke').trigger('click')
|
await wrapper.find('.revoke').trigger('click')
|
||||||
|
expect(api.post).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again')
|
||||||
|
expect(wrapper.find('.revoke-block').element.checked).toBe(false)
|
||||||
|
await wrapper.find('.revoke-confirm').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
|
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends blockreenroll with the reason when the block box is ticked', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
await openCredentials(wrapper)
|
||||||
|
await wrapper.find('.revoke').trigger('click')
|
||||||
|
await wrapper.find('.revoke-block').setValue(true)
|
||||||
|
await wrapper.find('.revoke-reason').setValue(' rollback ')
|
||||||
|
await wrapper.find('.revoke-confirm').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke',
|
||||||
|
{ blockreenroll: true, reason: 'rollback' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the block chip and allows re-enrollment after confirm', async () => {
|
||||||
|
const confirmSpy = vi.fn(() => true)
|
||||||
|
vi.stubGlobal('confirm', confirmSpy)
|
||||||
|
const wrapper = await render()
|
||||||
|
await openCredentials(wrapper)
|
||||||
|
const rows = wrapper.findAll('.credential-row')
|
||||||
|
const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK'))
|
||||||
|
const enrolled = rows.find(r => r.text().includes('PC-ENROLLED'))
|
||||||
|
expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked')
|
||||||
|
expect(enrolled.find('.block-chip').exists()).toBe(false)
|
||||||
|
expect(enrolled.find('.allow-reenroll').exists()).toBe(false)
|
||||||
|
await blocked.find('.allow-reenroll').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(confirmSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll')
|
||||||
|
})
|
||||||
|
|
||||||
it('shows the not-enrolled count before turning the shared key off', async () => {
|
it('shows the not-enrolled count before turning the shared key off', async () => {
|
||||||
const promptSpy = vi.fn(() => 'OFF')
|
const promptSpy = vi.fn(() => 'OFF')
|
||||||
vi.stubGlobal('prompt', promptSpy)
|
vi.stubGlobal('prompt', promptSpy)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ builds of 0021/0022, and on a re-run:
|
|||||||
table where an earlier build of 0021/0022 created them.
|
table where an earlier build of 0021/0022 created them.
|
||||||
2. Rename endpointapplications.app_fingerprint to appfingerprint where an
|
2. Rename endpointapplications.app_fingerprint to appfingerprint where an
|
||||||
earlier build of 0020 used the underscore spelling.
|
earlier build of 0020 used the underscore spelling.
|
||||||
3. Create endpointcredentials, endpointenrollrequests and
|
3. Create endpointcredentials (with the reenrollblocked flag and its reason),
|
||||||
endpointmigrationwaves.
|
endpointenrollrequests and endpointmigrationwaves.
|
||||||
|
|
||||||
MySQL 5.6 safe: no JSON columns, and the enroll request lookup index uses
|
MySQL 5.6 safe: no JSON columns, and the enroll request lookup index uses
|
||||||
prefix lengths so the key stays under 767 bytes on utf8mb4.
|
prefix lengths so the key stays under 767 bytes on utf8mb4.
|
||||||
@@ -109,12 +109,26 @@ def upgrade():
|
|||||||
sa.Column('lastusedat', sa.DateTime(), nullable=True),
|
sa.Column('lastusedat', sa.DateTime(), nullable=True),
|
||||||
sa.Column('revokedat', sa.DateTime(), nullable=True),
|
sa.Column('revokedat', sa.DateTime(), nullable=True),
|
||||||
sa.Column('revokedby', sa.String(length=64), nullable=True),
|
sa.Column('revokedby', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('reenrollblocked', sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.false()),
|
||||||
|
sa.Column('reenrollblockedreason', sa.String(length=64),
|
||||||
|
nullable=True),
|
||||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||||
ondelete='CASCADE'),
|
ondelete='CASCADE'),
|
||||||
sa.PrimaryKeyConstraint('credentialid'),
|
sa.PrimaryKeyConstraint('credentialid'),
|
||||||
sa.UniqueConstraint('assetid'),
|
sa.UniqueConstraint('assetid'),
|
||||||
sa.UniqueConstraint('credentialhash'),
|
sa.UniqueConstraint('credentialhash'),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# A test database that ran an earlier build of this revision.
|
||||||
|
columns = _columns(CREDENTIALS)
|
||||||
|
if 'reenrollblocked' not in columns:
|
||||||
|
op.add_column(CREDENTIALS, sa.Column(
|
||||||
|
'reenrollblocked', sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.false()))
|
||||||
|
if 'reenrollblockedreason' not in columns:
|
||||||
|
op.add_column(CREDENTIALS, sa.Column(
|
||||||
|
'reenrollblockedreason', sa.String(length=64), nullable=True))
|
||||||
|
|
||||||
if not _has_table(REQUESTS):
|
if not _has_table(REQUESTS):
|
||||||
op.create_table(
|
op.create_table(
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
One row per asset. Holds only the sha256 of the secret the
|
One row per asset. Holds only the sha256 of the secret the
|
||||||
agent was issued, plus a short display prefix. Revoking sets
|
agent was issued, plus a short display prefix. Revoking sets
|
||||||
revokedat; re-issuing after a revoke UPDATES the same row, so
|
revokedat; re-issuing after a revoke UPDATES the same row, so
|
||||||
an asset never carries two credentials.
|
an asset never carries two credentials. reenrollblocked keeps
|
||||||
|
a revoked PC from enrolling again (rolling it back to an agent
|
||||||
|
that only knows the shared key, or a compromised PC) until an
|
||||||
|
admin allows it.
|
||||||
|
|
||||||
EndpointEnrollRequest
|
EndpointEnrollRequest
|
||||||
One row per (hostname, serial number) that asked to enroll. The
|
One row per (hostname, serial number) that asked to enroll. The
|
||||||
@@ -49,6 +52,9 @@ class EndpointCredential(db.Model):
|
|||||||
lastusedat = db.Column(db.DateTime, nullable=True)
|
lastusedat = db.Column(db.DateTime, nullable=True)
|
||||||
revokedat = db.Column(db.DateTime, nullable=True)
|
revokedat = db.Column(db.DateTime, nullable=True)
|
||||||
revokedby = db.Column(db.String(64), nullable=True)
|
revokedby = db.Column(db.String(64), nullable=True)
|
||||||
|
reenrollblocked = db.Column(db.Boolean, nullable=False, default=False,
|
||||||
|
server_default=db.false())
|
||||||
|
reenrollblockedreason = db.Column(db.String(64), nullable=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isactive(self):
|
def isactive(self):
|
||||||
@@ -64,6 +70,8 @@ class EndpointCredential(db.Model):
|
|||||||
'lastusedat': _utciso(self.lastusedat),
|
'lastusedat': _utciso(self.lastusedat),
|
||||||
'revokedat': _utciso(self.revokedat),
|
'revokedat': _utciso(self.revokedat),
|
||||||
'revokedby': self.revokedby,
|
'revokedby': self.revokedby,
|
||||||
|
'reenrollblocked': bool(self.reenrollblocked),
|
||||||
|
'reenrollblockedreason': self.reenrollblockedreason,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ public static class ShopDbServerPinValidator
|
|||||||
return Pins.Contains(LastPresented);
|
return Pins.Contains(LastPresented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool IsPinned(string pin)
|
||||||
|
{
|
||||||
|
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
|
||||||
|
}
|
||||||
|
|
||||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||||
public static string SpkiSha256Hex(byte[] der)
|
public static string SpkiSha256Hex(byte[] der)
|
||||||
@@ -279,8 +284,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
|
|||||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||||
}
|
}
|
||||||
$presented = ''
|
$presented = ''
|
||||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
$matched = $false
|
||||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
if ('ShopDbServerPinValidator' -as [type]) {
|
||||||
|
$presented = [string][ShopDbServerPinValidator]::LastPresented
|
||||||
|
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
|
||||||
|
}
|
||||||
|
# Innermost exception text: the outer WebException hides the real cause
|
||||||
|
# (unexpected EOF, connection reset, trust failure).
|
||||||
|
$message = ''
|
||||||
|
$exception = $ErrorRecord.Exception
|
||||||
|
while ($exception) {
|
||||||
|
if ($exception.Message) { $message = $exception.Message }
|
||||||
|
$exception = $exception.InnerException
|
||||||
|
}
|
||||||
|
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-ShopDbAuthFailure($Failure) {
|
function Write-ShopDbAuthFailure($Failure) {
|
||||||
@@ -300,10 +317,17 @@ function Write-ShopDbAuthFailure($Failure) {
|
|||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||||
|
# A pin was seen, but only an absent pin is a pin failure. A
|
||||||
|
# matched pin means the handshake passed and something later
|
||||||
|
# failed (server dropped mid-transfer, timeout).
|
||||||
|
if ($Failure.PinMatched) {
|
||||||
|
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
|
||||||
|
} else {
|
||||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')
|
$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')
|
||||||
|
|||||||
@@ -267,6 +267,102 @@ def test_credential_is_invalid_after_revoke(client, db, app, keyheaders,
|
|||||||
assert reissued != secret
|
assert reissued != secret
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_with_block_keeps_the_pc_off_credentials(client, db, app,
|
||||||
|
keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
from plugins.endpoints.models import EndpointCredential
|
||||||
|
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
secret = _credential(client, keyheaders)
|
||||||
|
|
||||||
|
revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
json={'blockreenroll': True, 'reason': 'rollback'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert revoke.status_code == 200, revoke.get_data(as_text=True)
|
||||||
|
data = revoke.get_json()['data']
|
||||||
|
assert data['revokedat'] is not None
|
||||||
|
assert data['reenrollblocked'] is True
|
||||||
|
assert data['reenrollblockedreason'] == 'rollback'
|
||||||
|
|
||||||
|
# The current agent asks again at once: refused, with the reason.
|
||||||
|
again = _enroll(client, keyheaders)
|
||||||
|
assert again.status_code == 202, again.get_data(as_text=True)
|
||||||
|
assert again.get_json()['data']['reason'] == 'reenroll blocked'
|
||||||
|
assert client.get('/api/endpoints/alerts',
|
||||||
|
headers={'X-Endpoint-Credential': secret}).status_code == 401
|
||||||
|
|
||||||
|
# An older agent on the shared key keeps working.
|
||||||
|
assert client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||||
|
headers=keyheaders).status_code == 200
|
||||||
|
|
||||||
|
listing = client.get('/api/endpoints/enrollment',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
row = next(r for r in listing['computers'] if r['assetid'] == assetid)
|
||||||
|
assert row['reenrollblocked'] is True
|
||||||
|
assert row['reenrollblockedreason'] == 'rollback'
|
||||||
|
assert row['state'] == 'revoked'
|
||||||
|
|
||||||
|
# Blocked also means an approval cannot hand one out.
|
||||||
|
requestid = again.get_json()['data']['requestid']
|
||||||
|
approve = client.post(
|
||||||
|
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||||
|
json={'assetid': assetid}, headers=auth_headers)
|
||||||
|
assert approve.status_code == 409
|
||||||
|
|
||||||
|
allow = client.post(
|
||||||
|
f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert allow.status_code == 200, allow.get_data(as_text=True)
|
||||||
|
assert allow.get_json()['data']['reenrollblocked'] is False
|
||||||
|
row = EndpointCredential.query.filter_by(assetid=assetid).one()
|
||||||
|
assert row.reenrollblockedreason is None
|
||||||
|
|
||||||
|
reissued = _credential(client, keyheaders)
|
||||||
|
assert reissued != secret
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_pc_under_shared_key_off_is_refused_everything(
|
||||||
|
client, db, app, keyheaders, auth_headers):
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
_credential(client, keyheaders)
|
||||||
|
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
json={'blockreenroll': True}, headers=auth_headers)
|
||||||
|
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
|
||||||
|
|
||||||
|
refused = client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||||
|
headers=keyheaders)
|
||||||
|
assert refused.status_code == 401
|
||||||
|
assert _code(refused) == 'SHARED_KEY_REFUSED'
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_block_validation_and_revoked_rows(client, db, app, keyheaders,
|
||||||
|
auth_headers):
|
||||||
|
assetid = _computer(db, 'BAY-01')
|
||||||
|
nocredential = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||||
|
url = f'/api/endpoints/enrollment/assets/{assetid}/revoke'
|
||||||
|
_credential(client, keyheaders)
|
||||||
|
|
||||||
|
assert client.post(url, json={'blockreenroll': 'yes'},
|
||||||
|
headers=auth_headers).status_code == 400
|
||||||
|
assert client.post(url, json={'blockreenroll': True, 'reason': 'x' * 65},
|
||||||
|
headers=auth_headers).status_code == 400
|
||||||
|
|
||||||
|
# Plain revoke, then block the already-revoked credential afterwards.
|
||||||
|
assert client.post(url, headers=auth_headers).status_code == 200
|
||||||
|
assert client.post(url, headers=auth_headers).status_code == 404
|
||||||
|
blocked = client.post(url, json={'blockreenroll': True},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert blocked.status_code == 200
|
||||||
|
assert blocked.get_json()['data']['reenrollblocked'] is True
|
||||||
|
|
||||||
|
assert client.post(
|
||||||
|
f'/api/endpoints/enrollment/assets/{nocredential}/revoke',
|
||||||
|
json={'blockreenroll': True}, headers=auth_headers).status_code == 404
|
||||||
|
assert client.post(
|
||||||
|
f'/api/endpoints/enrollment/assets/{nocredential}/allow-reenroll',
|
||||||
|
headers=auth_headers).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_garbage_credential_is_invalid(client, db, app):
|
def test_garbage_credential_is_invalid(client, db, app):
|
||||||
response = client.get('/api/endpoints/commands/next',
|
response = client.get('/api/endpoints/commands/next',
|
||||||
headers={'X-Endpoint-Credential': 'sdbe_nope'})
|
headers={'X-Endpoint-Credential': 'sdbe_nope'})
|
||||||
@@ -349,7 +445,7 @@ def test_command_result_for_own_command_needs_no_hostname(client, db, app,
|
|||||||
assert response.status_code == 200, response.get_data(as_text=True)
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
def _fleet_alert(db):
|
def _fleet_alert(db, alertscope=None):
|
||||||
from plugins.notifications.models import Notification, NotificationType
|
from plugins.notifications.models import Notification, NotificationType
|
||||||
|
|
||||||
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
|
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
|
||||||
@@ -358,7 +454,7 @@ def _fleet_alert(db):
|
|||||||
db.session.flush()
|
db.session.flush()
|
||||||
note = Notification(notificationtypeid=ntype.notificationtypeid,
|
note = Notification(notificationtypeid=ntype.notificationtypeid,
|
||||||
notification='Line 3 is down', alertfleet=True,
|
notification='Line 3 is down', alertfleet=True,
|
||||||
alerttimeout=60, isactive=True)
|
alerttimeout=60, isactive=True, alertscope=alertscope)
|
||||||
db.session.add(note)
|
db.session.add(note)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return note.notificationid
|
return note.notificationid
|
||||||
@@ -402,6 +498,37 @@ def test_alert_ack_with_no_assetid_means_the_caller(client, db, app,
|
|||||||
notificationid=notificationid, assetid=assetid).count() == 1
|
notificationid=notificationid, assetid=assetid).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_ack_refuses_an_alert_not_scoped_to_the_caller(
|
||||||
|
client, db, app, keyheaders):
|
||||||
|
from plugins.notifications.models import NotificationAcknowledgement
|
||||||
|
|
||||||
|
_computer(db, 'BAY-01')
|
||||||
|
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||||
|
_computer(db, 'BAY-03', serialnumber='SER-3')
|
||||||
|
secret = _credential(client, keyheaders)
|
||||||
|
notificationid = _fleet_alert(db, alertscope={'hostname': ['BAY-02',
|
||||||
|
'BAY-03']})
|
||||||
|
url = f'/api/endpoints/alerts/{notificationid}/acknowledge'
|
||||||
|
unknown = client.post('/api/endpoints/alerts/999999/acknowledge', json={},
|
||||||
|
headers={'X-Endpoint-Credential': secret})
|
||||||
|
|
||||||
|
bycredential = client.post(url, json={},
|
||||||
|
headers={'X-Endpoint-Credential': secret})
|
||||||
|
assert bycredential.status_code == 404
|
||||||
|
assert bycredential.get_json()['data']['error']['code'] == \
|
||||||
|
unknown.get_json()['data']['error']['code']
|
||||||
|
|
||||||
|
bysharedkey = client.post(url, json={'hostname': 'BAY-03'},
|
||||||
|
headers=keyheaders)
|
||||||
|
assert bysharedkey.status_code == 201, bysharedkey.get_data(as_text=True)
|
||||||
|
|
||||||
|
_computer(db, 'BAY-04', serialnumber='SER-4')
|
||||||
|
outside = client.post(url, json={'hostname': 'BAY-04'}, headers=keyheaders)
|
||||||
|
assert outside.status_code == 404
|
||||||
|
assert NotificationAcknowledgement.query.filter_by(
|
||||||
|
notificationid=notificationid).count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_collector_computers_refuses_another_hostname(client, db, app,
|
def test_collector_computers_refuses_another_hostname(client, db, app,
|
||||||
keyheaders):
|
keyheaders):
|
||||||
_computer(db, 'BAY-01')
|
_computer(db, 'BAY-01')
|
||||||
@@ -487,6 +614,8 @@ def test_contract_exports_answer_through_the_endpoints_plugin(client, db, app,
|
|||||||
('post', '/api/endpoints/enrollment/requests/1/approve'),
|
('post', '/api/endpoints/enrollment/requests/1/approve'),
|
||||||
('post', '/api/endpoints/enrollment/requests/1/reject'),
|
('post', '/api/endpoints/enrollment/requests/1/reject'),
|
||||||
('post', '/api/endpoints/enrollment/assets/1/revoke'),
|
('post', '/api/endpoints/enrollment/assets/1/revoke'),
|
||||||
|
('post', '/api/endpoints/enrollment/assets/1/allow-reenroll'),
|
||||||
|
('get', '/api/endpoints/settings/server-cert/download'),
|
||||||
('put', '/api/endpoints/enrollment/settings'),
|
('put', '/api/endpoints/enrollment/settings'),
|
||||||
('post', '/api/endpoints/enrollment/migration/wave'),
|
('post', '/api/endpoints/enrollment/migration/wave'),
|
||||||
])
|
])
|
||||||
@@ -586,4 +715,17 @@ def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path,
|
|||||||
response = client.get('/api/endpoints/settings/server-cert',
|
response = client.get('/api/endpoints/settings/server-cert',
|
||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
assert response.status_code == 200, response.get_data(as_text=True)
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
assert response.get_json()['data']['spkisha256'] == expected
|
data = response.get_json()['data']
|
||||||
|
assert data['spkisha256'] == expected
|
||||||
|
sha1 = certificate.fingerprint(hashes.SHA1()).hex().upper()
|
||||||
|
assert data['sha1thumbprint'] == sha1
|
||||||
|
assert ':' not in data['sha1thumbprint']
|
||||||
|
|
||||||
|
download = client.get('/api/endpoints/settings/server-cert/download',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert download.status_code == 200
|
||||||
|
assert download.mimetype == 'application/pkix-cert'
|
||||||
|
assert 'attachment' in download.headers['Content-Disposition']
|
||||||
|
assert '.cer' in download.headers['Content-Disposition']
|
||||||
|
assert download.data == certificate.public_bytes(serialization.Encoding.DER)
|
||||||
|
assert b'PRIVATE' not in download.data
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ def _assert_head_schema(connection):
|
|||||||
credentialcolumns = {c['name'] for c in inspector.get_columns('endpointcredentials')}
|
credentialcolumns = {c['name'] for c in inspector.get_columns('endpointcredentials')}
|
||||||
assert credentialcolumns == {
|
assert credentialcolumns == {
|
||||||
'credentialid', 'assetid', 'credentialhash', 'credentialprefix',
|
'credentialid', 'assetid', 'credentialhash', 'credentialprefix',
|
||||||
'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby'}
|
'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby',
|
||||||
|
'reenrollblocked', 'reenrollblockedreason'}
|
||||||
requestindexes = [tuple(ix['column_names']) for ix in
|
requestindexes = [tuple(ix['column_names']) for ix in
|
||||||
inspector.get_indexes('endpointenrollrequests')]
|
inspector.get_indexes('endpointenrollrequests')]
|
||||||
assert requestindexes.count(('hostname', 'serialnumber')) == 1
|
assert requestindexes.count(('hostname', 'serialnumber')) == 1
|
||||||
|
|||||||
@@ -276,3 +276,140 @@ def test_wave_without_ready_pcs_creates_nothing(client, db, app, auth_headers):
|
|||||||
def test_wave_validation(client, db, app, auth_headers, body):
|
def test_wave_validation(client, db, app, auth_headers, body):
|
||||||
assert client.post(WAVE_URL, json=body,
|
assert client.post(WAVE_URL, json=body,
|
||||||
headers=auth_headers).status_code == 400
|
headers=auth_headers).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reenroll_blocked_pc_is_blocked_not_ready(client, db, app,
|
||||||
|
auth_headers):
|
||||||
|
from plugins.endpoints.api.enrollment import issue_credential
|
||||||
|
from plugins.endpoints.models import EndpointEnrollRequest
|
||||||
|
|
||||||
|
_publish_agent()
|
||||||
|
assetid = _pc(db, 'PC-ROLLBACK')
|
||||||
|
issue_credential(assetid, 'auto')
|
||||||
|
db.session.commit()
|
||||||
|
response = client.post(
|
||||||
|
f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||||
|
json={'blockreenroll': True, 'reason': 'rollback'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)
|
||||||
|
# Its agent keeps asking; that request must not turn the row into pending.
|
||||||
|
db.session.add(EndpointEnrollRequest(
|
||||||
|
hostname='PC-ROLLBACK', serialnumber='SER', enrollnonce='n' * 40,
|
||||||
|
status='pending', reason='reenroll blocked', matchedassetid=assetid,
|
||||||
|
requestedat=_now(), lastseenat=_now()))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
row = _by_host(_rows(client, auth_headers))['PC-ROLLBACK']
|
||||||
|
assert row['state'] == 'blocked'
|
||||||
|
assert row['blockers'] == ['reenroll blocked']
|
||||||
|
|
||||||
|
wave = client.post(WAVE_URL, json={'assetids': [assetid]},
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
assert wave['skipped'] == [{'assetid': assetid,
|
||||||
|
'reason': 'reenroll blocked'}]
|
||||||
|
|
||||||
|
client.post(f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert _by_host(_rows(client, auth_headers))['PC-ROLLBACK']['state'] \
|
||||||
|
== 'pending'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('message,detail', [
|
||||||
|
('Script download failed: The SSL connection could not be established, '
|
||||||
|
'see inner exception.', None),
|
||||||
|
('Script download failed', {'error': 'The remote certificate is invalid '
|
||||||
|
'because of errors in the certificate chain: '
|
||||||
|
'UntrustedRoot'}),
|
||||||
|
('Invoke-WebRequest: Could not establish trust relationship for the '
|
||||||
|
'SSL/TLS secure channel.', None),
|
||||||
|
])
|
||||||
|
def test_a_tls_trust_failure_blocks_the_pc(client, db, app, auth_headers,
|
||||||
|
message, detail):
|
||||||
|
import json
|
||||||
|
|
||||||
|
from plugins.endpoints.models import EndpointCommand
|
||||||
|
|
||||||
|
_publish_agent()
|
||||||
|
assetid = _pc(db, 'PC-UNTRUSTED')
|
||||||
|
otherid = _pc(db, 'PC-OTHERFAIL')
|
||||||
|
agentregistry.register_current_agent_package()
|
||||||
|
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||||
|
db.session.add(EndpointCommand(
|
||||||
|
assetid=assetid, action='run_script', target=f'script:{scriptid}',
|
||||||
|
status=EndpointCommand.STATUS_FAILED, resultmessage=message,
|
||||||
|
resultjson=json.dumps(detail) if detail is not None else None,
|
||||||
|
completedat=_now()))
|
||||||
|
db.session.add(EndpointCommand(
|
||||||
|
assetid=otherid, action='run_script', target=f'script:{scriptid}',
|
||||||
|
status=EndpointCommand.STATUS_FAILED,
|
||||||
|
resultmessage='Script exited with code 1', completedat=_now()))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
rows = _by_host(_rows(client, auth_headers))
|
||||||
|
assert rows['PC-UNTRUSTED']['state'] == 'blocked'
|
||||||
|
assert rows['PC-UNTRUSTED']['blockers'] == [
|
||||||
|
'server certificate not trusted by this PC']
|
||||||
|
assert rows['PC-OTHERFAIL']['state'] == 'ready'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers):
|
||||||
|
from plugins.endpoints.models import EndpointCommand
|
||||||
|
|
||||||
|
_publish_agent()
|
||||||
|
assetid = _pc(db, 'PC-FIXED')
|
||||||
|
agentregistry.register_current_agent_package()
|
||||||
|
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||||
|
db.session.add(EndpointCommand(
|
||||||
|
assetid=assetid, action='run_script', target=f'script:{scriptid}',
|
||||||
|
status=EndpointCommand.STATUS_FAILED,
|
||||||
|
resultmessage='RemoteCertificateChainErrors', completedat=_now()))
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(EndpointCommand(
|
||||||
|
assetid=assetid, action='run_script', target='script:999',
|
||||||
|
status=EndpointCommand.STATUS_SUCCEEDED, completedat=_now()))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready'
|
||||||
|
|
||||||
|
|
||||||
|
def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app,
|
||||||
|
auth_headers):
|
||||||
|
from plugins.endpoints.models import EndpointCommand, EndpointState
|
||||||
|
|
||||||
|
_publish_agent()
|
||||||
|
upgraded = _pc(db, 'PC-UPGRADED')
|
||||||
|
retried = _pc(db, 'PC-RETRIED')
|
||||||
|
stuck = _pc(db, 'PC-STUCK')
|
||||||
|
first = client.post(WAVE_URL, json={'assetids': [upgraded, retried, stuck]},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert first.status_code == 201, first.get_data(as_text=True)
|
||||||
|
for command in EndpointCommand.query.all():
|
||||||
|
command.status = EndpointCommand.STATUS_FAILED
|
||||||
|
db.session.commit()
|
||||||
|
assert _rows(client, auth_headers)['waves'][0]['failed'] == 3
|
||||||
|
|
||||||
|
second = client.post(WAVE_URL, json={'assetids': [upgraded, retried]},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert second.status_code == 201, second.get_data(as_text=True)
|
||||||
|
# The later wave replaced the agent on PC-UPGRADED, which restarted before
|
||||||
|
# reporting, so its command expired; it has not enrolled yet. PC-RETRIED's
|
||||||
|
# push reported success and is waiting to enroll on its old version string.
|
||||||
|
latest = {command.assetid: command for command in EndpointCommand.query
|
||||||
|
.filter_by(status=EndpointCommand.STATUS_PENDING).all()}
|
||||||
|
latest[upgraded].status = EndpointCommand.STATUS_EXPIRED
|
||||||
|
latest[retried].status = EndpointCommand.STATUS_SUCCEEDED
|
||||||
|
EndpointState.query.filter_by(assetid=upgraded).one().agentversion = '1.20.0'
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
failed = {wave['waveid']: wave['failed']
|
||||||
|
for wave in _rows(client, auth_headers)['waves']}
|
||||||
|
assert failed == {first.get_json()['data']['waveid']: 1,
|
||||||
|
second.get_json()['data']['waveid']: 0}
|
||||||
|
|
||||||
|
# A newer failure counts again, in every wave the PC belongs to.
|
||||||
|
latest[retried].status = EndpointCommand.STATUS_FAILED
|
||||||
|
db.session.commit()
|
||||||
|
failed = {wave['waveid']: wave['failed']
|
||||||
|
for wave in _rows(client, auth_headers)['waves']}
|
||||||
|
assert failed == {first.get_json()['data']['waveid']: 2,
|
||||||
|
second.get_json()['data']['waveid']: 1}
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Site agent package build: server pins, optional shared key, Root import gate.
|
||||||
|
|
||||||
|
The package is the only place a PC gets its first trust anchor and (optionally)
|
||||||
|
the shared fleet key, so these tests read the actual ZIP the route returns
|
||||||
|
rather than the helper in isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
from plugins.endpoints.api import agentstore, siteagent
|
||||||
|
from shopdb.core.models.setting import Setting
|
||||||
|
|
||||||
|
PACKAGE_URL = '/api/endpoints/siteagent/package'
|
||||||
|
STATUS_URL = '/api/endpoints/siteagent'
|
||||||
|
SECRET = 'sharedkeyvalue-0123456789abcdef'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cert(isca):
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,
|
||||||
|
'ca.test' if isca else 'server.test')])
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
builder = (x509.CertificateBuilder()
|
||||||
|
.subject_name(name).issuer_name(name)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - datetime.timedelta(days=1))
|
||||||
|
.not_valid_after(now + datetime.timedelta(days=30)))
|
||||||
|
if isca:
|
||||||
|
builder = builder.add_extension(
|
||||||
|
x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
certificate = builder.sign(key, hashes.SHA256())
|
||||||
|
pem = certificate.public_bytes(serialization.Encoding.PEM)
|
||||||
|
spki = certificate.public_key().public_bytes(
|
||||||
|
serialization.Encoding.DER,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||||
|
return pem, hashlib.sha256(spki).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate(tmp_path, monkeypatch):
|
||||||
|
"""Empty agent store and no auto-detected server certificate by default."""
|
||||||
|
store = tmp_path / 'siteagent'
|
||||||
|
store.mkdir()
|
||||||
|
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
|
||||||
|
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def servercert(tmp_path, monkeypatch):
|
||||||
|
"""A self-signed leaf staged as this server's own certificate."""
|
||||||
|
pem, pin = _make_cert(isca=False)
|
||||||
|
path = tmp_path / 'server.crt'
|
||||||
|
path.write_bytes(pem)
|
||||||
|
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
||||||
|
return pin
|
||||||
|
|
||||||
|
|
||||||
|
def _build(client, headers, **fields):
|
||||||
|
body = {'baseurl': 'https://shopdb.test', 'cadenceseconds': 900}
|
||||||
|
body.update(fields)
|
||||||
|
return client.post(PACKAGE_URL, json=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _files(response):
|
||||||
|
assert response.status_code == 200, response.get_data(as_text=True)[:400]
|
||||||
|
archive = zipfile.ZipFile(io.BytesIO(response.data))
|
||||||
|
return {name: archive.read(name) for name in archive.namelist()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_writes_current_server_pin(client, db, auth_headers, servercert):
|
||||||
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||||
|
includesharedkey=True, bundlecert=True))
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
|
||||||
|
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
|
||||||
|
assert 'Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins"' in script
|
||||||
|
assert '[Microsoft.Win32.RegistryValueKind]::MultiString' in script
|
||||||
|
assert '-ceq $wanted' in script
|
||||||
|
settings = json.loads(files['appsettings.json'])
|
||||||
|
assert settings['serverpins'] == [servercert]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_comes_from_server_cert_even_when_not_bundled(
|
||||||
|
client, db, auth_headers, servercert):
|
||||||
|
files = _files(_build(client, auth_headers, collectorkey=SECRET))
|
||||||
|
assert 'server.crt' not in files
|
||||||
|
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert]
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_pin_is_shipped_beside_current(client, db, auth_headers, servercert):
|
||||||
|
nextpin = 'ab' * 32
|
||||||
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||||
|
nextserverpin=nextpin))
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
|
||||||
|
assert '@("{0}", "{1}")'.format(servercert, nextpin) in script
|
||||||
|
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert, nextpin]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('badpin', ['AB' * 32, 'ab' * 31, 'zz' * 32])
|
||||||
|
def test_next_pin_must_be_64_lowercase_hex(client, db, auth_headers, servercert, badpin):
|
||||||
|
response = _build(client, auth_headers, collectorkey=SECRET, nextserverpin=badpin)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_pin_without_a_current_pin_is_refused(client, db, auth_headers):
|
||||||
|
response = _build(client, auth_headers, collectorkey=SECRET,
|
||||||
|
nextserverpin='ab' * 32)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_key_on_keeps_key_provisioning(client, db, auth_headers, servercert):
|
||||||
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||||
|
includesharedkey='true'))
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
|
||||||
|
assert '[string] $CollectorKey = "{0}"'.format(SECRET) in script
|
||||||
|
assert '-Name "CollectorKey" -Value $CollectorKey' in script
|
||||||
|
assert 'Settings > Endpoints > Enrollment' not in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_key_off_leaves_no_key_anywhere(client, db, auth_headers, servercert):
|
||||||
|
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||||
|
includesharedkey=False))
|
||||||
|
|
||||||
|
for name, content in files.items():
|
||||||
|
assert b'CollectorKey' not in content, name
|
||||||
|
assert SECRET.encode('ascii') not in content, name
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
assert 'Settings > Endpoints > Enrollment' in script
|
||||||
|
assert '$sharedKeyMode = "omitted"' in script
|
||||||
|
assert json.loads(files['appsettings.json'])['apikey'] == ''
|
||||||
|
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_key_default_follows_site_setting(client, db, auth_headers, servercert):
|
||||||
|
# Setting on (default): key required.
|
||||||
|
assert _build(client, auth_headers).status_code == 400
|
||||||
|
|
||||||
|
Setting.set('plugin.endpoints.sharedkeyallowed', False, valuetype='boolean')
|
||||||
|
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
|
||||||
|
assert status['sharedkeyallowed'] is False
|
||||||
|
assert status['serverpin'] == servercert
|
||||||
|
|
||||||
|
files = _files(_build(client, auth_headers, collectorkey=SECRET))
|
||||||
|
assert b'CollectorKey' not in files['install.ps1']
|
||||||
|
assert SECRET.encode('ascii') not in files['install.ps1']
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_never_removes_enrolled_credential(client, db, auth_headers, servercert):
|
||||||
|
script = _files(_build(client, auth_headers, collectorkey=SECRET))[
|
||||||
|
'install.ps1'].decode('ascii')
|
||||||
|
assert 'existing enrolled credential found; kept as is.' in script
|
||||||
|
for line in script.splitlines():
|
||||||
|
if 'Remove-Item' in line:
|
||||||
|
assert 'credential' not in line.lower(), line
|
||||||
|
|
||||||
|
|
||||||
|
def test_ca_certificate_is_not_imported_into_root(client, db, auth_headers, servercert):
|
||||||
|
capem, capin = _make_cert(isca=True)
|
||||||
|
response = client.post(
|
||||||
|
PACKAGE_URL,
|
||||||
|
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
|
||||||
|
'cadenceseconds': '900',
|
||||||
|
'certfile': (io.BytesIO(capem), 'ca.crt')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
files = _files(response)
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
|
||||||
|
assert 'server.crt' in files
|
||||||
|
assert '$trustCertInRoot = $false' in script
|
||||||
|
assert 'is a CA certificate; not importing it' in script
|
||||||
|
# A CA cannot be the pinned leaf; the server's own leaf is pinned instead.
|
||||||
|
pins = json.loads(files['appsettings.json'])['serverpins']
|
||||||
|
assert pins == [servercert]
|
||||||
|
assert capin not in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):
|
||||||
|
leafpem, leafpin = _make_cert(isca=False)
|
||||||
|
response = client.post(
|
||||||
|
PACKAGE_URL,
|
||||||
|
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
|
||||||
|
'cadenceseconds': '900',
|
||||||
|
'certfile': (io.BytesIO(leafpem), 'server.crt')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
files = _files(response)
|
||||||
|
script = files['install.ps1'].decode('ascii')
|
||||||
|
|
||||||
|
assert '$trustCertInRoot = $true' in script
|
||||||
|
assert 'Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root' in script
|
||||||
|
assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin]
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_script_helper_without_pins_or_key():
|
||||||
|
script = siteagent._install_script('https://shopdb.test', '', 900,
|
||||||
|
serverpins=[], includesharedkey=False)
|
||||||
|
assert '[string[]] $ServerPins = @()' in script
|
||||||
|
assert 'CollectorKey' not in script
|
||||||
|
assert 'ServerPins left as is' in script
|
||||||
|
|||||||
Reference in New Issue
Block a user