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:
cproudlock
2026-09-15 13:37:37 -04:00
parent 31ed9a5fde
commit da4b224ac1
20 changed files with 1713 additions and 151 deletions

View File

@@ -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);
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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),

View File

@@ -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])?)*$')

View File

@@ -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')

View File

@@ -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"
}
}
}
}

View 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

View File

@@ -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

View File

@@ -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 &gt;
Endpoints &gt; 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) {

View File

@@ -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;

View File

@@ -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)

View File

@@ -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(

View File

@@ -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):

View File

@@ -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"
}
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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