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).
|
||||
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
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, cert, _, errors)
|
||||
@@ -57,11 +60,9 @@ public sealed class AgentHttpFactory
|
||||
// server keep-alive, or a reused dead socket yields "unexpected
|
||||
// EOF or 0 bytes from the transport stream" (1.6.2).
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
|
||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
||||
break;
|
||||
case AgentHttpChannel.FleetAlert:
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
|
||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -176,40 +177,94 @@ public sealed record AgentServerError(string Code, string Message)
|
||||
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
|
||||
// configured only a matching leaf is accepted and chain/name errors are ignored
|
||||
// (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 const string RegistryValue = "ServerPins";
|
||||
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HashSet<string> spkiPins;
|
||||
private readonly string? legacyThumbprint;
|
||||
private readonly ILogger logger;
|
||||
|
||||
private ServerPinSet(HashSet<string> spkiPins, string? legacyThumbprint, ILogger logger)
|
||||
private sealed record Snapshot(HashSet<string> SpkiPins, string RawRegistry)
|
||||
{
|
||||
this.spkiPins = spkiPins;
|
||||
this.legacyThumbprint = legacyThumbprint;
|
||||
this.logger = logger;
|
||||
public string Signature => string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var pins = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var pin in config.ServerPins ?? new List<string>())
|
||||
AddPin(pins, pin, "appsettings.json", logger);
|
||||
foreach (var pin in ReadRegistryPins(logger))
|
||||
AddPin(pins, pin, "registry", logger);
|
||||
|
||||
string? thumbprint = null;
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
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(
|
||||
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||
pins.Count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||
pins.Count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
return new ServerPinSet(pins, thumbprint, logger);
|
||||
count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
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)
|
||||
@@ -220,7 +275,8 @@ public sealed class ServerPinSet
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsPinned)
|
||||
var snapshot = Current();
|
||||
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null)
|
||||
{
|
||||
if (errors == SslPolicyErrors.None)
|
||||
return true;
|
||||
@@ -235,7 +291,7 @@ public sealed class ServerPinSet
|
||||
try
|
||||
{
|
||||
var spki = SpkiSha256(leaf);
|
||||
if (spkiPins.Contains(spki))
|
||||
if (snapshot.SpkiPins.Contains(spki))
|
||||
return true;
|
||||
if (legacyThumbprint is not null
|
||||
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -270,7 +326,9 @@ public sealed class ServerPinSet
|
||||
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>();
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
@@ -291,6 +349,11 @@ public sealed class ServerPinSet
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,11 @@ public static class ShopDbServerPinValidator
|
||||
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),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
@@ -271,8 +276,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||
}
|
||||
$presented = ''
|
||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
||||
$matched = $false
|
||||
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) {
|
||||
@@ -292,7 +309,14 @@ function Write-ShopDbAuthFailure($Failure) {
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
credential. Otherwise the request is pending with one of the reasons `unknown
|
||||
hostname`, `duplicate hostname`, `no serial on record`, `serial mismatch`,
|
||||
`already enrolled`, `awaiting approval`, `nonce changed`. The credential is in
|
||||
the 201 body exactly once.
|
||||
`already enrolled`, `awaiting approval`, `nonce changed`, `reenroll blocked`.
|
||||
`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
|
||||
|
||||
@@ -646,7 +648,9 @@ as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
|
||||
`POST /api/endpoints/operatorreports`, `GET /api/endpoints/alerts`,
|
||||
`POST /api/endpoints/alerts/<id>/acknowledge`. With a credential, a missing
|
||||
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
|
||||
for the SYSTEM reporter scripts.
|
||||
|
||||
@@ -673,6 +677,14 @@ for the SYSTEM reporter scripts.
|
||||
(`spkisha256` on `GET /api/endpoints/settings/server-cert`);
|
||||
`client/Get-ShopDBServerPin.ps1` computes it from a URL or a file;
|
||||
`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
|
||||
in the set (chain and name errors ignored). No pin: require a clean Windows
|
||||
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`)
|
||||
- `POST /api/endpoints/enrollment/requests/<id>/approve` `{"assetid": n}`
|
||||
- `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}`
|
||||
- `GET /api/endpoints/enrollment/migration` (`page`, `perpage` <= 100, `state`, `search`)
|
||||
- `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
|
||||
token). Accepted only while
|
||||
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
|
||||
enroll response.
|
||||
@@ -79,6 +81,18 @@ def active_credential_for(assetid):
|
||||
.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):
|
||||
"""Asset id for an active credential, else None."""
|
||||
from ..models import EndpointCredential
|
||||
|
||||
@@ -7,7 +7,8 @@ Agent-facing:
|
||||
Admin (endpoints.fleet to read, admin role to change):
|
||||
GET /enrollment credential state per computer + requests
|
||||
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
|
||||
GET /enrollment/migration readiness of every computer for agent 1.20
|
||||
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_NONCE_CHANGED = 'nonce changed'
|
||||
REASON_REJECTED = 'rejected'
|
||||
REASON_REENROLL_BLOCKED = 'reenroll blocked'
|
||||
|
||||
MIGRATION_STATES = ('enrolled', 'pending', 'upgrading', 'ready', 'blocked',
|
||||
'notreporting')
|
||||
@@ -71,6 +73,18 @@ BLOCKER_NO_PACKAGE = 'no agent package in store'
|
||||
BLOCKER_TOO_OLD = 'agent too old for remote upgrade'
|
||||
BLOCKER_POLICY = 'run_script not permitted by policy'
|
||||
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)
|
||||
_WAVE_MAXIMUM = 100
|
||||
@@ -224,6 +238,8 @@ def enroll_endpoint():
|
||||
'installation', http_code=403)
|
||||
if agentauth.active_credential_for(row.matchedassetid) is not None:
|
||||
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:
|
||||
return _pending(row, REASON_UNKNOWN_HOSTNAME)
|
||||
return _issue(row, row.matchedassetid,
|
||||
@@ -274,6 +290,8 @@ def enroll_endpoint():
|
||||
return _pending(row, REASON_SERIAL_MISMATCH, assetid)
|
||||
if agentauth.active_credential_for(assetid) is not None:
|
||||
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():
|
||||
return _issue(row, assetid, EndpointCredential.ENROLLED_AUTO)
|
||||
return _pending(row, REASON_AWAITING_APPROVAL, assetid)
|
||||
@@ -347,8 +365,12 @@ def get_enrollment():
|
||||
rows = []
|
||||
for assetid, hostname, serialnumber in _computers():
|
||||
credential = credentials.get(assetid)
|
||||
blocked = bool(credential is not None and credential.reenrollblocked)
|
||||
if credential is not None and credential.revokedat is None:
|
||||
state = 'enrolled'
|
||||
elif blocked:
|
||||
# Its agent keeps asking; those requests are expected, not pending work.
|
||||
state = 'revoked'
|
||||
elif _is_pending(assetid, hostname, index):
|
||||
state = 'pending'
|
||||
elif credential is not None:
|
||||
@@ -364,6 +386,9 @@ def get_enrollment():
|
||||
'issuedat': _utciso(credential.issuedat) if credential else None,
|
||||
'lastusedat': _utciso(credential.lastusedat) 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)
|
||||
@@ -430,6 +455,10 @@ def approve_enroll_request(requestid):
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
'That computer is already enrolled; revoke its '
|
||||
'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.matchedassetid = assetid
|
||||
@@ -467,23 +496,83 @@ def reject_enroll_request(requestid):
|
||||
@endpoints_bp.route('/enrollment/assets/<int:assetid>/revoke', methods=['POST'])
|
||||
@require_role('admin')
|
||||
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)
|
||||
if credential is None and blockreenroll:
|
||||
credential = EndpointCredential.query.filter_by(assetid=assetid).first()
|
||||
if credential is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'This computer has no active credential',
|
||||
http_code=404)
|
||||
credential.revokedat = _naive_utcnow()
|
||||
credential.revokedby = _username()
|
||||
AuditLog.log('deleted', 'EndpointCredential', entityid=assetid,
|
||||
entityname=credential.credentialprefix,
|
||||
details='endpoint credential {} revoked'.format(
|
||||
credential.credentialprefix))
|
||||
|
||||
wasactive = credential.revokedat is None
|
||||
if wasactive:
|
||||
credential.revokedat = _naive_utcnow()
|
||||
credential.revokedby = _username()
|
||||
details = []
|
||||
if wasactive:
|
||||
details.append('endpoint credential {} revoked'.format(
|
||||
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()
|
||||
return success_response(credential.to_dict(),
|
||||
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'])
|
||||
@require_role('admin')
|
||||
def update_enrollment_settings():
|
||||
@@ -546,6 +635,28 @@ def _store_has_package():
|
||||
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):
|
||||
"""Migration state for every active computer (or just assetids).
|
||||
|
||||
@@ -562,8 +673,11 @@ def compute_readiness(assetids=None):
|
||||
return []
|
||||
|
||||
ids = [row[0] for row in computers]
|
||||
active = {c.assetid for c in EndpointCredential.query
|
||||
.filter(EndpointCredential.revokedat.is_(None)).all()}
|
||||
credentialrows = EndpointCredential.query.filter(
|
||||
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())
|
||||
states = {s.assetid: s for s in
|
||||
EndpointState.query.filter(EndpointState.assetid.in_(ids)).all()}
|
||||
@@ -579,6 +693,8 @@ def compute_readiness(assetids=None):
|
||||
(EndpointCommand.STATUS_PENDING,
|
||||
EndpointCommand.STATUS_RUNNING)))}
|
||||
|
||||
lastscripts = _latest_commands(ids)
|
||||
|
||||
haspackage = _store_has_package()
|
||||
minimum = minimum_agent_version_for('run_script')
|
||||
offlinecutoff = _naive_utcnow() - _OFFLINE_AFTER
|
||||
@@ -591,6 +707,9 @@ def compute_readiness(assetids=None):
|
||||
blockers = []
|
||||
if assetid in active:
|
||||
status = 'enrolled'
|
||||
elif assetid in reenrollblocked:
|
||||
status = 'blocked'
|
||||
blockers.append(BLOCKER_REENROLL_BLOCKED)
|
||||
elif _is_pending(assetid, hostname, index):
|
||||
status = 'pending'
|
||||
elif assetid in upgrading:
|
||||
@@ -610,6 +729,8 @@ def compute_readiness(assetids=None):
|
||||
blockers.append(BLOCKER_NO_PACKAGE)
|
||||
if minimum and not agent_meets_minimum(agentversion, minimum):
|
||||
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):
|
||||
blockers.append(BLOCKER_AWAITING_ENROLLMENT)
|
||||
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):
|
||||
"""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
|
||||
enrolled = set()
|
||||
failed = set()
|
||||
@@ -637,21 +765,23 @@ def _wave_progress(wave, scriptid):
|
||||
EndpointCredential.assetid.in_(members),
|
||||
EndpointCredential.revokedat.is_(None)).all()}
|
||||
if scriptid is not None:
|
||||
commands = (EndpointCommand.query
|
||||
.filter(EndpointCommand.assetid.in_(members),
|
||||
EndpointCommand.action == 'run_script',
|
||||
EndpointCommand.target == f'script:{scriptid}',
|
||||
EndpointCommand.createdat >= wave.createdat)
|
||||
.order_by(EndpointCommand.createdat.asc())
|
||||
.all())
|
||||
latest = {}
|
||||
for command in commands:
|
||||
queued.add(command.assetid)
|
||||
latest[command.assetid] = command
|
||||
failed = {assetid for assetid, command in latest.items()
|
||||
target = f'script:{scriptid}'
|
||||
queued = {row[0] for row in db.session.query(EndpointCommand.assetid)
|
||||
.filter(EndpointCommand.assetid.in_(members),
|
||||
EndpointCommand.action == 'run_script',
|
||||
EndpointCommand.target == target,
|
||||
EndpointCommand.createdat >= wave.createdat)
|
||||
.distinct()}
|
||||
versions = dict(db.session.query(EndpointState.assetid,
|
||||
EndpointState.agentversion)
|
||||
.filter(EndpointState.assetid.in_(members)).all())
|
||||
failed = {assetid for assetid, command
|
||||
in _latest_commands(members, target).items()
|
||||
if assetid not in enrolled
|
||||
and command.status in (EndpointCommand.STATUS_FAILED,
|
||||
EndpointCommand.STATUS_EXPIRED)}
|
||||
EndpointCommand.STATUS_EXPIRED)
|
||||
and _version_below(versions.get(assetid),
|
||||
CREDENTIAL_AGENT_VERSION)}
|
||||
return {
|
||||
'waveid': wave.waveid,
|
||||
'createdat': _utciso(wave.createdat),
|
||||
|
||||
@@ -4164,6 +4164,14 @@ def acknowledge_fleet_alert(notification_id: int):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'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)
|
||||
existing = (NotificationAcknowledgement.query
|
||||
.filter_by(notificationid=notification_id, assetid=assetid)
|
||||
@@ -4196,6 +4204,21 @@ def acknowledge_fleet_alert(notification_id: int):
|
||||
# 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):
|
||||
"""Lowercase hex SHA-256 of a PEM certificate's SubjectPublicKeyInfo.
|
||||
|
||||
@@ -4299,6 +4322,7 @@ def get_server_certificate():
|
||||
'not_after': not_after,
|
||||
'thumbprint': thumbprint,
|
||||
'spkisha256': _spki_sha256(cert_path.read_bytes()),
|
||||
'sha1thumbprint': _sha1_thumbprint(cert_path.read_bytes()),
|
||||
'san_list': san_list,
|
||||
'expires_in_days': expires_in_days,
|
||||
'is_self_signed': is_self_signed
|
||||
@@ -4316,6 +4340,36 @@ def get_server_certificate():
|
||||
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(
|
||||
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])?)*$')
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
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 json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import zipfile
|
||||
|
||||
@@ -34,9 +41,12 @@ from flask import request, current_app, send_file
|
||||
from shopdb.api import (success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
|
||||
from .routes import endpoints_bp
|
||||
from .routes import endpoints_bp, _spki_sha256
|
||||
from .agentauth import shared_key_allowed
|
||||
from . import agentstore
|
||||
|
||||
_PIN_PATTERN = re.compile(r'^[0-9a-f]{64}$')
|
||||
|
||||
# Prebuilt, staged agent binaries. Built out of band and dropped under
|
||||
# instance/siteagent/ (mirrors the adoption tool's STAGED_EXE_RELPATH model).
|
||||
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
|
||||
@@ -110,6 +120,49 @@ def _validate_pem_cert(raw):
|
||||
return None
|
||||
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
|
||||
# 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.
|
||||
@@ -175,20 +228,42 @@ FILES
|
||||
USAGE
|
||||
1. Copy this whole folder to the target endpoint.
|
||||
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
|
||||
registers the tray to autostart for every user at logon and adds a
|
||||
"ShopDB Agent" Start Menu shortcut for manual relaunch.
|
||||
|
||||
{enrollment}
|
||||
UNINSTALL
|
||||
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
|
||||
agent, tray autostart, Start Menu shortcut, and credentials.
|
||||
|
||||
SECURITY
|
||||
install.ps1 carries a live collector key in its default -CollectorKey value.
|
||||
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.
|
||||
"""
|
||||
{security}"""
|
||||
|
||||
_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):
|
||||
@@ -196,7 +271,9 @@ def _staged_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 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
|
||||
the machine Trusted Root store so the agent's outbound TLS to a self-signed
|
||||
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)
|
||||
collectorkey_literal = json.dumps(collectorkey)
|
||||
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
|
||||
# ShopDB endpoint agent installer. Run from an ELEVATED (admin) PowerShell on
|
||||
# the endpoint. Provisions the registry credentials, copies the agent to
|
||||
@@ -219,7 +320,7 @@ def _install_script(baseurl, collectorkey, cadenceseconds, certfilename=None):
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BaseUrl = {baseurl},
|
||||
[string] $CollectorKey = {collectorkey},
|
||||
{keyparam} [string[]] $ServerPins = {serverpins},
|
||||
[string] $InstallDir = "C:\\Program Files\\ShopDB\\EndpointAgent",
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
@@ -251,9 +352,8 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {{
|
||||
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
||||
}}
|
||||
if ([string]::IsNullOrWhiteSpace($CollectorKey)) {{
|
||||
throw "CollectorKey is not set. Regenerate the key in the endpoints UI and rebuild."
|
||||
}}
|
||||
{keycheck}$sharedKeyMode = "{sharedkeymode}"
|
||||
$trustCertInRoot = {trustcert}
|
||||
|
||||
# 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
|
||||
@@ -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 "BaseUrl : $BaseUrl"
|
||||
Write-Host "InstallDir : $InstallDir"
|
||||
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.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
@@ -306,21 +451,48 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
|
||||
# 1. Provision registry credentials (both 64-bit and 32-bit views).
|
||||
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 "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
|
||||
# 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.
|
||||
# No-op when the deployment uses a publicly trusted (or already-trusted internal)
|
||||
# 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)) {{
|
||||
$certPath = Join-Path $sourceDir $CertFileName
|
||||
if (Test-Path $certPath) {{
|
||||
Write-Host " trusting bundled server certificate $CertFileName ..."
|
||||
try {{
|
||||
Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root | Out-Null
|
||||
$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 ..."
|
||||
try {{
|
||||
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 {{
|
||||
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."
|
||||
}}
|
||||
Write-Host " service is running." -ForegroundColor Green
|
||||
|
||||
{enrollnotice}
|
||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
# 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
|
||||
# ProgramData so the uninstaller and appsettings/cert are available later, then
|
||||
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
|
||||
# collector key in its default parameter, so it must not linger on the endpoint.
|
||||
# scrub install.ps1 from wherever it was run. A shared-key build carries the live
|
||||
# key in its default parameter, so it must not linger on the endpoint.
|
||||
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
|
||||
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
|
||||
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"
|
||||
if (Test-Path $installSelf) {{
|
||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
|
||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
|
||||
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1." }}
|
||||
catch {{ Write-Warning "could not remove install.ps1 - delete it manually: $($_.Exception.Message)" }}
|
||||
}}
|
||||
|
||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||
@@ -639,7 +811,7 @@ try {{
|
||||
$ledgerTrayVersion = "not-installed"
|
||||
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
|
||||
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
|
||||
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
|
||||
$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
|
||||
Write-Host "Install ledger: $ledgerPath"
|
||||
Write-Host " $ledgerLine" -ForegroundColor DarkGray
|
||||
@@ -655,7 +827,13 @@ else {{
|
||||
}}
|
||||
""".format(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -672,6 +850,8 @@ def _siteagent_status_payload():
|
||||
'agentstaged': agent_staged,
|
||||
'traystaged': tray_staged,
|
||||
'certavailable': _server_cert_path() is not None,
|
||||
'serverpin': _current_server_pin(),
|
||||
'sharedkeyallowed': shared_key_allowed(),
|
||||
'agentcurrent': agentstore.current_version('agent'),
|
||||
'traycurrent': agentstore.current_version('tray'),
|
||||
'agentversions': agentstore.list_versions('agent'),
|
||||
@@ -694,7 +874,12 @@ def package_siteagent():
|
||||
"""Bundle the site agent as a downloadable ZIP.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -717,11 +902,25 @@ def package_siteagent():
|
||||
collectorkey = (source.get('collectorkey') or '').strip()
|
||||
cadenceseconds = source.get('cadenceseconds', 900)
|
||||
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(
|
||||
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)
|
||||
|
||||
try:
|
||||
@@ -739,6 +938,8 @@ def package_siteagent():
|
||||
|
||||
appsettings = dict(_BASE_APPSETTINGS)
|
||||
appsettings['cadenceseconds'] = cadenceseconds
|
||||
if not includesharedkey:
|
||||
appsettings['apikey'] = ''
|
||||
|
||||
# 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
|
||||
@@ -758,14 +959,43 @@ def package_siteagent():
|
||||
|
||||
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()
|
||||
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('appsettings.json',
|
||||
json.dumps(appsettings, indent=2) + '\n')
|
||||
archive.writestr('install.ps1',
|
||||
_install_script(baseurl.rstrip('/'), collectorkey,
|
||||
cadenceseconds, certfilename))
|
||||
archive.writestr('README.txt', _README)
|
||||
cadenceseconds, certfilename,
|
||||
serverpins=serverpins,
|
||||
includesharedkey=includesharedkey,
|
||||
trustcertinroot=trustcertinroot))
|
||||
archive.writestr('README.txt', _readme(includesharedkey))
|
||||
|
||||
if os.path.isfile(UNINSTALLER_PATH):
|
||||
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
|
||||
|
||||
@@ -126,6 +126,11 @@ public static class ShopDbServerPinValidator
|
||||
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),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
@@ -264,8 +269,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||
}
|
||||
$presented = ''
|
||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
||||
$matched = $false
|
||||
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) {
|
||||
@@ -285,7 +302,14 @@ function Write-ShopDbAuthFailure($Failure) {
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
`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
|
||||
RESPONSE line.
|
||||
|
||||
**Rotating the server key later:** put the old AND the new pin in `-Pins`
|
||||
(`"<oldpin>,<newpin>"`), wait until every PC has both, switch the certificate,
|
||||
then drop the old pin.
|
||||
**Rotating the server key later:** any server-side change that gives the
|
||||
certificate a new key (a regenerated or re-issued certificate) changes the 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
|
||||
|
||||
@@ -158,7 +207,10 @@ Repeat until **Ready** is 0.
|
||||
`no serial on record` / `duplicate hostname`: fix the computer record.
|
||||
`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
|
||||
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
|
||||
share channel: add its hostname to a targeted 1.20 agent entry. It shows as
|
||||
Pending or Enrolled once the agent runs.
|
||||
@@ -208,22 +260,49 @@ read it until this table is done.
|
||||
|
||||
## 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:**
|
||||
|
||||
1. Make sure **Shared key allowed** is on.
|
||||
2. Credentials tab -> **Un-enroll** the PC. Its credential stops working
|
||||
immediately; the server admits the shared key for it again.
|
||||
3. Push the previous agent: Upload New/Legacy Agents -> make the legacy build
|
||||
current and upgrade that PC, or re-stamp its share entry to the old hash.
|
||||
4. Remove the PC from the pin entry's targets only if pinning itself is the
|
||||
2. Credentials tab -> **Un-enroll** the PC with **Keep this PC from enrolling
|
||||
again** ticked. API equivalent:
|
||||
`POST /api/endpoints/enrollment/assets/<assetid>/revoke`
|
||||
`{"blockreenroll": true, "reason": "rollback"}`.
|
||||
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).
|
||||
|
||||
A 1.20 agent that is un-enrolled but NOT downgraded simply enrolls again on its
|
||||
next cycle; that is expected.
|
||||
6. Later, when moving the PC forward again, lift the block:
|
||||
`POST /api/endpoints/enrollment/assets/<assetid>/allow-reenroll` (or the
|
||||
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
|
||||
holds the shared key reports again immediately. Then un-enroll and downgrade
|
||||
PCs as above, in waves.
|
||||
holds the shared key reports again immediately. Then revoke with the block and
|
||||
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
|
||||
|
||||
|
||||
@@ -222,10 +222,11 @@
|
||||
<div class="card form-card">
|
||||
<p class="field-hint">
|
||||
Produce an instance-locked agent package stamped with this server's
|
||||
URL, the endpoint agent key, and the reporting cadence, so it deploys
|
||||
with no manual configuration on the client. The download includes the
|
||||
agent, a non-secret <code>appsettings.json</code>, and an
|
||||
<code>install.ps1</code> that writes the credentials to the registry
|
||||
URL, server pin, optionally the endpoint agent key, and the reporting
|
||||
cadence, so it deploys with no manual configuration on the client. The
|
||||
download includes the agent, a non-secret
|
||||
<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
|
||||
appsettings.
|
||||
</p>
|
||||
@@ -245,14 +246,46 @@
|
||||
placeholder="https://shopdb.example"
|
||||
/>
|
||||
|
||||
<label class="field-label" for="agent-key">Endpoint agent key</label>
|
||||
<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>
|
||||
<input
|
||||
id="agent-key"
|
||||
v-model="agentcollectorkey"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
placeholder="Paste the endpoint agent key generated above"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<label class="field-label" for="agent-nextpin">
|
||||
Next server pin (optional)
|
||||
</label>
|
||||
<input
|
||||
id="agent-key"
|
||||
v-model="agentcollectorkey"
|
||||
id="agent-nextpin"
|
||||
v-model.trim="agentnextserverpin"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
placeholder="Paste the endpoint agent key generated above"
|
||||
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">
|
||||
Reporting cadence (seconds)
|
||||
@@ -287,9 +320,10 @@
|
||||
/>
|
||||
<p class="field-hint">
|
||||
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
|
||||
validated, bundled with the package, and imported into the endpoint's
|
||||
Trusted Root store by <code>install.ps1</code>. Leave empty when the
|
||||
the server's public certificate in PEM format. It is validated,
|
||||
bundled with the package, pinned, and imported into the endpoint's
|
||||
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
|
||||
over the bundled server certificate above.
|
||||
</p>
|
||||
@@ -298,7 +332,9 @@
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
|
||||
:disabled="agentbusy || !agentbaseurl
|
||||
|| (agentincludesharedkey && !agentcollectorkey)
|
||||
|| agentnextpininvalid()"
|
||||
@click="buildSiteAgent"
|
||||
>
|
||||
{{ agentbusy ? 'Building...' : 'Build site agent' }}
|
||||
@@ -529,6 +565,14 @@ const agentcertfile = ref(null)
|
||||
const agentbusy = ref(false)
|
||||
const agentmessage = 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
|
||||
// version list, and busy/message state so the two rows act independently. The
|
||||
@@ -594,6 +638,8 @@ onMounted(async () => {
|
||||
agentstaged.value = !!status.agentstaged
|
||||
certavailable.value = !!status.certavailable
|
||||
agentbundlecert.value = !!status.certavailable
|
||||
agentincludesharedkey.value = status.sharedkeyallowed !== false
|
||||
agentserverpin.value = status.serverpin || ''
|
||||
applyBinaryStatus('agent', status.agentcurrent, status.agentversions)
|
||||
applyBinaryStatus('tray', status.traycurrent, status.trayversions)
|
||||
} catch (statusError) {
|
||||
@@ -717,7 +763,13 @@ async function buildSiteAgent() {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('baseurl', agentbaseurl.value)
|
||||
form.append('collectorkey', agentcollectorkey.value)
|
||||
if (agentincludesharedkey.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('bundlecert', agentbundlecert.value ? 'true' : 'false')
|
||||
if (agentcertfile.value) {
|
||||
|
||||
@@ -374,29 +374,83 @@
|
||||
<tr v-if="!credentialRows.length">
|
||||
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No computers match.</td>
|
||||
</tr>
|
||||
<tr v-for="row in credentialRows" :key="row.assetid">
|
||||
<td>{{ row.hostname || '-' }}</td>
|
||||
<td>{{ row.serialnumber || '-' }}</td>
|
||||
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
|
||||
<td>
|
||||
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ formatDate(row.issuedat) }}</td>
|
||||
<td>{{ formatDate(row.lastusedat) }}</td>
|
||||
<td>{{ row.enrolledvia || '-' }}</td>
|
||||
<td v-if="isadmin" class="action-cell">
|
||||
<button
|
||||
v-if="row.state === 'enrolled'"
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm revoke"
|
||||
:disabled="busy"
|
||||
@click="revoke(row)"
|
||||
>
|
||||
Un-enroll
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="row in credentialRows" :key="row.assetid">
|
||||
<tr class="credential-row">
|
||||
<td>{{ row.hostname || '-' }}</td>
|
||||
<td>{{ row.serialnumber || '-' }}</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>
|
||||
<code v-if="row.credentialprefix" class="mono">{{ row.credentialprefix }}...</code>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ formatDate(row.issuedat) }}</td>
|
||||
<td>{{ formatDate(row.lastusedat) }}</td>
|
||||
<td>{{ row.enrolledvia || '-' }}</td>
|
||||
<td v-if="isadmin" class="action-cell">
|
||||
<button
|
||||
v-if="row.state === 'enrolled'"
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm revoke"
|
||||
:disabled="busy"
|
||||
@click="openRevoke(row)"
|
||||
>
|
||||
Un-enroll
|
||||
</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>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -518,7 +572,9 @@ function waveConfirmText(count) {
|
||||
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 '
|
||||
+ '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) {
|
||||
@@ -729,15 +785,37 @@ async function reject(request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row) {
|
||||
const fallback = enrollmentSettings.value.sharedkeyallowed
|
||||
const revoking = ref(null)
|
||||
|
||||
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.'
|
||||
: '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
|
||||
try {
|
||||
await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`)
|
||||
toast.success(`Un-enrolled ${row.hostname}`)
|
||||
const url = `/endpoints/enrollment/assets/${current.assetid}/revoke`
|
||||
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 })
|
||||
} catch (error) {
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
function stateLabel(state) {
|
||||
@@ -982,6 +1075,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
.approve-form .form-control { max-width: 260px; }
|
||||
|
||||
.block-chip { margin-left: 0.35rem; }
|
||||
|
||||
.revoke-row .form-control { max-width: 260px; }
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -54,7 +54,11 @@ function enrollmentPayload() {
|
||||
counts: { enrolled: 40, notenrolled: 58, pending: 2 },
|
||||
computers: [
|
||||
{ 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: [
|
||||
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
|
||||
@@ -125,6 +129,8 @@ describe('EnrollmentSettings', () => {
|
||||
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(/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 })
|
||||
})
|
||||
|
||||
@@ -166,14 +172,46 @@ describe('EnrollmentSettings', () => {
|
||||
})
|
||||
|
||||
it('un-enrolls through the revoke route', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
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()
|
||||
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 () => {
|
||||
const promptSpy = vi.fn(() => 'OFF')
|
||||
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.
|
||||
2. Rename endpointapplications.app_fingerprint to appfingerprint where an
|
||||
earlier build of 0020 used the underscore spelling.
|
||||
3. Create endpointcredentials, endpointenrollrequests and
|
||||
endpointmigrationwaves.
|
||||
3. Create endpointcredentials (with the reenrollblocked flag and its reason),
|
||||
endpointenrollrequests and endpointmigrationwaves.
|
||||
|
||||
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.
|
||||
@@ -109,12 +109,26 @@ def upgrade():
|
||||
sa.Column('lastusedat', sa.DateTime(), nullable=True),
|
||||
sa.Column('revokedat', sa.DateTime(), 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'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('credentialid'),
|
||||
sa.UniqueConstraint('assetid'),
|
||||
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):
|
||||
op.create_table(
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
One row per asset. Holds only the sha256 of the secret the
|
||||
agent was issued, plus a short display prefix. Revoking sets
|
||||
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
|
||||
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)
|
||||
revokedat = db.Column(db.DateTime, 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
|
||||
def isactive(self):
|
||||
@@ -64,6 +70,8 @@ class EndpointCredential(db.Model):
|
||||
'lastusedat': _utciso(self.lastusedat),
|
||||
'revokedat': _utciso(self.revokedat),
|
||||
'revokedby': self.revokedby,
|
||||
'reenrollblocked': bool(self.reenrollblocked),
|
||||
'reenrollblockedreason': self.reenrollblockedreason,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -141,6 +141,11 @@ public static class ShopDbServerPinValidator
|
||||
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),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
@@ -279,8 +284,20 @@ function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
|
||||
}
|
||||
$presented = ''
|
||||
if ('ShopDbServerPinValidator' -as [type]) { $presented = [string][ShopDbServerPinValidator]::LastPresented }
|
||||
return @{ Status = $status; Code = $code; PresentedPin = $presented }
|
||||
$matched = $false
|
||||
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) {
|
||||
@@ -300,7 +317,14 @@ function Write-ShopDbAuthFailure($Failure) {
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,102 @@ def test_credential_is_invalid_after_revoke(client, db, app, keyheaders,
|
||||
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):
|
||||
response = client.get('/api/endpoints/commands/next',
|
||||
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)
|
||||
|
||||
|
||||
def _fleet_alert(db):
|
||||
def _fleet_alert(db, alertscope=None):
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
|
||||
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
|
||||
@@ -358,7 +454,7 @@ def _fleet_alert(db):
|
||||
db.session.flush()
|
||||
note = Notification(notificationtypeid=ntype.notificationtypeid,
|
||||
notification='Line 3 is down', alertfleet=True,
|
||||
alerttimeout=60, isactive=True)
|
||||
alerttimeout=60, isactive=True, alertscope=alertscope)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
keyheaders):
|
||||
_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/reject'),
|
||||
('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'),
|
||||
('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',
|
||||
headers=auth_headers)
|
||||
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')}
|
||||
assert credentialcolumns == {
|
||||
'credentialid', 'assetid', 'credentialhash', 'credentialprefix',
|
||||
'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby'}
|
||||
'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby',
|
||||
'reenrollblocked', 'reenrollblockedreason'}
|
||||
requestindexes = [tuple(ix['column_names']) for ix in
|
||||
inspector.get_indexes('endpointenrollrequests')]
|
||||
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):
|
||||
assert client.post(WAVE_URL, json=body,
|
||||
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