Add the enrollment page, move scripts to per-PC credentials, and document the rollout
- Settings > Endpoints > Enrollment. Migration tab: readiness counts and progress, each PC's state with the reasons it is blocked, "Upgrade selected" and "Upgrade next 25", and live wave progress. Credentials tab: enrolled PCs with revoke, pending requests with approve and reject, and the shared key and auto-enroll switches. Turning the shared key off asks for confirmation and shows how many PCs are not enrolled. - The server certificate page shows the SPKI pin to put in the GE-Enforce pin entry. - The agent upgrade script and the asset and printer reporters send the PC's credential when it has one (over https, only to the issuing server), fall back to the shared key, check server pins, and log refusals clearly. - New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1. - AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers phases, gates, rollback, pin and wave entries, and every place the shared key lives and how to clear it at cutover. - Removed the 09-14 certificate design notes and dated session reports, and two VM notes that carried a live collector key and advised plain http. - Corrected the GE-Enforce collector note, which claimed a plain registry token is protected by ACL.
This commit is contained in:
@@ -17,11 +17,18 @@
|
||||
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
|
||||
#
|
||||
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
|
||||
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
|
||||
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
|
||||
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
|
||||
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
|
||||
# key into the manifest JSON on the share.
|
||||
# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+)
|
||||
# has enrolled this PC, its per-PC credential is read from the agent's
|
||||
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
|
||||
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
|
||||
# collector.ingest key is sent as the X-API-Key header, read from
|
||||
# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (same secret store the GE-Enforce client
|
||||
# uses; provisioned at imaging), or overridden via the manifest entry's Args
|
||||
# -ApiKey. Never bake the key into the manifest JSON on the share.
|
||||
#
|
||||
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
|
||||
# must match one of them or nothing is sent. See
|
||||
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
|
||||
#
|
||||
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
|
||||
# identity with no machineNo, so no PC-to-machine link is built - by design.
|
||||
@@ -70,6 +77,227 @@ function Log([string]$msg) {
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-PC credential and server pin. Kept in step with the same block in
|
||||
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
|
||||
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
|
||||
#
|
||||
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
|
||||
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
|
||||
# Administrators. When present it is sent as X-Endpoint-Credential and the
|
||||
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
|
||||
#
|
||||
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
|
||||
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
|
||||
# is set the TLS handshake is accepted only if the leaf key matches one.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
|
||||
|
||||
$ShopDbPinSource = @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinValidator
|
||||
{
|
||||
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
public static string LastPresented;
|
||||
|
||||
public static void Install(string[] pins)
|
||||
{
|
||||
Pins.Clear();
|
||||
foreach (string pin in pins)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
|
||||
}
|
||||
ServicePointManager.ServerCertificateValidationCallback = Validate;
|
||||
}
|
||||
|
||||
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
|
||||
if (certificate == null) { return false; }
|
||||
try
|
||||
{
|
||||
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LastPresented = null;
|
||||
return false;
|
||||
}
|
||||
return Pins.Contains(LastPresented);
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Get-ShopDbServerPins {
|
||||
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
|
||||
$pins = @()
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
||||
try {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {
|
||||
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
||||
if ($key) {
|
||||
try {
|
||||
foreach ($value in @($key.GetValue('ServerPins'))) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
|
||||
}
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
return ,$pins
|
||||
}
|
||||
|
||||
function Enable-ShopDbServerPins([string[]]$Pins) {
|
||||
# True when pinning is active. False means pins are set but could not be
|
||||
# enforced; the caller must not send anything then.
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
|
||||
# would silently not happen.
|
||||
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
if (-not ('ShopDbServerPinValidator' -as [type])) {
|
||||
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
|
||||
}
|
||||
[ShopDbServerPinValidator]::Install($Pins)
|
||||
return $true
|
||||
} catch {
|
||||
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
|
||||
# Empty when there is none or it cannot be used for this URL.
|
||||
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
||||
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
||||
} catch {
|
||||
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
|
||||
return ''
|
||||
}
|
||||
$credential = [string]$record.credential
|
||||
if (-not $credential.StartsWith('sdbe_')) {
|
||||
Log 'WARN endpoint credential file has no usable credential; ignoring it'
|
||||
return ''
|
||||
}
|
||||
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
|
||||
return ''
|
||||
}
|
||||
# Never hand a credential to a server other than the one that issued it.
|
||||
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
|
||||
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
|
||||
return ''
|
||||
}
|
||||
return $credential
|
||||
}
|
||||
|
||||
function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
# Status and error envelope code (data.error.code) from a failed request.
|
||||
$status = 0
|
||||
$code = ''
|
||||
$text = ''
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$text = $ErrorRecord.ErrorDetails.Message
|
||||
} else {
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
|
||||
$text = $reader.ReadToEnd()
|
||||
$reader.Dispose()
|
||||
} catch {}
|
||||
}
|
||||
if ($text) {
|
||||
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 }
|
||||
}
|
||||
|
||||
function Write-ShopDbAuthFailure($Failure) {
|
||||
switch ($Failure.Code) {
|
||||
'CREDENTIAL_INVALID' {
|
||||
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
|
||||
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
|
||||
}
|
||||
'SHARED_KEY_REFUSED' {
|
||||
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
|
||||
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
|
||||
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
|
||||
}
|
||||
'ASSET_MISMATCH' {
|
||||
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
|
||||
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
||||
# running this script is running the enforcement client, which cannot work
|
||||
# without BaseUrl, so it is present wherever this is deployed.
|
||||
@@ -102,11 +330,21 @@ if (-not $ApiKey) {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $ApiKey) {
|
||||
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
|
||||
if (-not $EndpointCredential -and -not $ApiKey) {
|
||||
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$serverPins = Get-ShopDbServerPins
|
||||
if ($serverPins.Count -gt 0) {
|
||||
if (-not (Enable-ShopDbServerPins $serverPins)) {
|
||||
Log 'ERROR refusing to report without the server pin check. Skipping.'
|
||||
exit 0
|
||||
}
|
||||
Log "server pin check on ($($serverPins.Count) pin(s))"
|
||||
}
|
||||
|
||||
# -AllowedRanges and the CollectorRanges registry value are ACCEPTED AND
|
||||
# IGNORED. Deciding which NIC is the PC's address moved to the server, where
|
||||
# the site's addressing actually lives: this script now reports every NIC and
|
||||
@@ -326,14 +564,27 @@ $json = $body | ConvertTo-Json -Compress -Depth 4
|
||||
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
|
||||
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||
-ContentType 'application/json' `
|
||||
-Headers @{ 'X-API-Key' = $ApiKey } `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
|
||||
} catch {
|
||||
Log "ERROR POST failed: $($_.Exception.Message)"
|
||||
# Credential first; the shared key only when there is no credential, or when
|
||||
# the server has just refused the credential (revoked: the shared key is
|
||||
# admitted again for this PC until the agent re-enrolls). Never both at once.
|
||||
$attempts = @()
|
||||
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
|
||||
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
|
||||
|
||||
foreach ($attempt in $attempts) {
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||
-ContentType 'application/json' `
|
||||
-Headers $attempt[1] `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($resp | ConvertTo-Json -Compress -Depth 4))
|
||||
break
|
||||
} catch {
|
||||
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
|
||||
$failure = Get-ShopDbHttpFailure $_
|
||||
Write-ShopDbAuthFailure $failure
|
||||
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
|
||||
}
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -12,7 +12,8 @@ schema changes, update this file in the same change.
|
||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
|
||||
until the first UI key is generated, then it is switched off (section 7). No
|
||||
querystring fallback.
|
||||
querystring fallback. Agents 1.20 and later enroll and then send
|
||||
`X-Endpoint-Credential` instead of the shared key (section 9).
|
||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||
report per endpoint.
|
||||
- Identity field: `hostname` (required, case-insensitive).
|
||||
@@ -578,3 +579,130 @@ oversized payload rejected by the server returns nothing at all.
|
||||
Timed-out runs do not carry output: the script is still running and still holds
|
||||
the capture file open, so anything read at that moment would be misleading.
|
||||
|
||||
## 9. Per-PC credentials
|
||||
|
||||
Each endpoint authenticates with its OWN secret, readable only by SYSTEM on
|
||||
that PC. The server derives the calling asset from the secret. The shared fleet
|
||||
key (section 7) keeps working for PCs that have not enrolled until an admin
|
||||
switches it off, and is refused for any PC that has enrolled. The rollout is in
|
||||
`docs/PER-PC-CREDENTIALS-ROLLOUT.md`.
|
||||
|
||||
### 9.1 Headers
|
||||
|
||||
| State | Header | Value |
|
||||
|---|---|---|
|
||||
| Not enrolled | `X-API-Key` | the shared endpoints key, as today |
|
||||
| Enrolled | `X-Endpoint-Credential` | `sdbe_` + 43 chars base64url (32 random bytes) |
|
||||
|
||||
Never both on one request. The server stores only the SHA-256 of the full
|
||||
credential plus its first 12 characters (`credentialprefix`) for display.
|
||||
|
||||
### 9.2 Enroll
|
||||
|
||||
`POST {baseurl}/api/endpoints/enroll`, authenticated with `X-API-Key` while
|
||||
`plugin.endpoints.sharedkeyallowed` is true. With the shared key switched off
|
||||
the route takes no auth, but then only returns a credential for a request an
|
||||
admin has approved.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"hostname": "SHOP-PC-014", // 1-255
|
||||
"serialnumber": "5CD1234ABC", // 1-128, Win32_BIOS.SerialNumber
|
||||
"deviceid": null, // HKLM\SOFTWARE\GE\ShopDB DeviceId or null
|
||||
"enrollnonce": "<43 chars>", // 32-128 [A-Za-z0-9_-], generated once, persisted
|
||||
"agentversion": "1.20.0"
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Body | Agent action |
|
||||
|---|---|---|
|
||||
| 201 | `{"status":"enrolled","credential":"sdbe_...","assetid":123}` | store, switch headers, no restart |
|
||||
| 202 | `{"status":"pending","requestid":45,"reason":"serial mismatch"}` | retry every 15 min with jitter |
|
||||
| 403 `ENROLL_NONCE_MISMATCH` | error envelope | an approved request belongs to a different nonce; keep retrying, an admin must reject and re-approve |
|
||||
| other | error envelope | back off 1, 5, 15 min (cap) |
|
||||
|
||||
Auto-issue happens only when `plugin.endpoints.autoenroll` is true, the shared
|
||||
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.
|
||||
|
||||
### 9.3 Error codes
|
||||
|
||||
All use the standard error envelope (`data.error.code`).
|
||||
|
||||
| HTTP | Code | Meaning | Agent action |
|
||||
|---|---|---|---|
|
||||
| 401 | `CREDENTIAL_INVALID` | unknown or revoked credential | delete credential.bin, fall back to the shared key, wake enrollment |
|
||||
| 401 | `SHARED_KEY_REFUSED` | shared key sent, but this PC is enrolled or the shared key is switched off | wake enrollment; use the credential |
|
||||
| 403 | `ASSET_MISMATCH` | the credential's asset differs from the hostname/assetid named in the request | log; do not retry the same request |
|
||||
|
||||
Routes that authenticate this way: `POST /api/endpoints/collect` (same handler
|
||||
as `/api/collector/endpoints`), `GET /api/endpoints/monitors/resolved`,
|
||||
`GET /api/endpoints/commands/next`, `POST /api/endpoints/commands/<id>/result`,
|
||||
`GET /api/endpoints/apppackage/...`, `GET /api/endpoints/scriptbody/<id>`,
|
||||
`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
|
||||
(`/api/collector/computers`, `/api/collector/printers`) accepts the same header
|
||||
for the SYSTEM reporter scripts.
|
||||
|
||||
### 9.4 Storage on the endpoint
|
||||
|
||||
- `%ProgramData%\ShopDB\EndpointAgent\credential\` is ACLed SYSTEM and
|
||||
Administrators only.
|
||||
- `credential.bin` = DPAPI `LocalMachine` of UTF-8 JSON
|
||||
`{"credential":"sdbe_...","assetid":123,"issuedat":"...","baseurl":"..."}`.
|
||||
SYSTEM scripts read it with
|
||||
`[System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine')`
|
||||
and must not send the credential to a URL outside `baseurl`.
|
||||
- `enroll.json` = `{"enrollnonce":"...","createdat":"..."}`, plain, same folder.
|
||||
- The shared key in the registry and appsettings.json is never deleted by the
|
||||
agent; clearing it is a cutover step in the runbook.
|
||||
|
||||
### 9.5 Server pins
|
||||
|
||||
- `HKLM\SOFTWARE\GE\ShopDB` `ServerPins` (REG_MULTI_SZ, both registry views)
|
||||
merged with `serverpins` (string array) in appsettings.json. Legacy
|
||||
`servercertthumbprint` is still honoured.
|
||||
- A pin is the lowercase hex SHA-256 of the server leaf certificate's
|
||||
SubjectPublicKeyInfo. The Server Certificate settings page shows it
|
||||
(`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.
|
||||
- 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.
|
||||
|
||||
### 9.6 Tray pipe
|
||||
|
||||
The tray holds no secret and talks to the SYSTEM agent over
|
||||
`\\.\pipe\ShopDB.EndpointAgent.v1` (SYSTEM and Administrators full control,
|
||||
INTERACTIVE read/write, 8 instances). One UTF-8 JSON object per line, request
|
||||
then response, then close; request max 64 KB, read timeout 10 s.
|
||||
|
||||
| Request | Response |
|
||||
|---|---|
|
||||
| `{"op":"ping"}` | `{"ok":true,"agentversion":"1.20.0","enrolled":true,"assetid":123}` |
|
||||
| `{"op":"sendstatus"}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
|
||||
| `{"op":"sendreport","spoolpath":"C:\\Users\\<user>\\...\\report-xxxx"}` | `{"ok":true}` or `{"ok":false,"error":"..."}`; the path must be inside the caller's own profile and not a reparse point |
|
||||
| `{"op":"alerts"}` | `{"ok":true,"alerts":[ ...same objects as GET /alerts... ]}` |
|
||||
| `{"op":"ackalert","notificationid":42}` | `{"ok":true}` or `{"ok":false,"error":"..."}` |
|
||||
|
||||
The caller is identified from the pipe (impersonation user name and SID), never
|
||||
from the request body.
|
||||
|
||||
### 9.7 Admin API (WebUI side, for reference)
|
||||
|
||||
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)
|
||||
- `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)
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
# succeeds, the agent that would have reported it has been restarted. The new
|
||||
# version is confirmed on the next check-in. This script's own transcript is
|
||||
# the local record; see $LogFile below.
|
||||
#
|
||||
# AUTH AND PINS
|
||||
# The download authenticates the way the agent does: the per-PC credential from
|
||||
# the agent's SYSTEM-only credential.bin when this PC has enrolled, otherwise
|
||||
# the shared CollectorKey. When ServerPins is set (registry, or serverpins in
|
||||
# appsettings.json) the server's TLS key must match a pin or nothing is
|
||||
# downloaded. A 1.19 agent upgrading to 1.20 has no credential yet and uses the
|
||||
# shared key; the new agent enrolls itself after it starts.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -59,6 +67,230 @@ function Write-Log {
|
||||
try { Add-Content -Path $LogFile -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
|
||||
# The shared credential block below logs through Log, as the reporters do.
|
||||
function Log([string] $msg) { Write-Log $msg }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-PC credential and server pin. Kept in step with the same block in
|
||||
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
|
||||
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
|
||||
#
|
||||
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
|
||||
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
|
||||
# Administrators. When present it is sent as X-Endpoint-Credential and the
|
||||
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
|
||||
#
|
||||
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
|
||||
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
|
||||
# is set the TLS handshake is accepted only if the leaf key matches one.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
|
||||
|
||||
$ShopDbPinSource = @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinValidator
|
||||
{
|
||||
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
public static string LastPresented;
|
||||
|
||||
public static void Install(string[] pins)
|
||||
{
|
||||
Pins.Clear();
|
||||
foreach (string pin in pins)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
|
||||
}
|
||||
ServicePointManager.ServerCertificateValidationCallback = Validate;
|
||||
}
|
||||
|
||||
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
|
||||
if (certificate == null) { return false; }
|
||||
try
|
||||
{
|
||||
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LastPresented = null;
|
||||
return false;
|
||||
}
|
||||
return Pins.Contains(LastPresented);
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Get-ShopDbServerPins {
|
||||
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
|
||||
$pins = @()
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
||||
try {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {
|
||||
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
||||
if ($key) {
|
||||
try {
|
||||
foreach ($value in @($key.GetValue('ServerPins'))) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
|
||||
}
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
return ,$pins
|
||||
}
|
||||
|
||||
function Enable-ShopDbServerPins([string[]]$Pins) {
|
||||
# True when pinning is active. False means pins are set but could not be
|
||||
# enforced; the caller must not send anything then.
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
|
||||
# would silently not happen.
|
||||
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
if (-not ('ShopDbServerPinValidator' -as [type])) {
|
||||
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
|
||||
}
|
||||
[ShopDbServerPinValidator]::Install($Pins)
|
||||
return $true
|
||||
} catch {
|
||||
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
|
||||
# Empty when there is none or it cannot be used for this URL.
|
||||
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
||||
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
||||
} catch {
|
||||
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
|
||||
return ''
|
||||
}
|
||||
$credential = [string]$record.credential
|
||||
if (-not $credential.StartsWith('sdbe_')) {
|
||||
Log 'WARN endpoint credential file has no usable credential; ignoring it'
|
||||
return ''
|
||||
}
|
||||
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
|
||||
return ''
|
||||
}
|
||||
# Never hand a credential to a server other than the one that issued it.
|
||||
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
|
||||
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
|
||||
return ''
|
||||
}
|
||||
return $credential
|
||||
}
|
||||
|
||||
function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
# Status and error envelope code (data.error.code) from a failed request.
|
||||
$status = 0
|
||||
$code = ''
|
||||
$text = ''
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$text = $ErrorRecord.ErrorDetails.Message
|
||||
} else {
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
|
||||
$text = $reader.ReadToEnd()
|
||||
$reader.Dispose()
|
||||
} catch {}
|
||||
}
|
||||
if ($text) {
|
||||
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 }
|
||||
}
|
||||
|
||||
function Write-ShopDbAuthFailure($Failure) {
|
||||
switch ($Failure.Code) {
|
||||
'CREDENTIAL_INVALID' {
|
||||
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
|
||||
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
|
||||
}
|
||||
'SHARED_KEY_REFUSED' {
|
||||
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
|
||||
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
|
||||
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
|
||||
}
|
||||
'ASSET_MISMATCH' {
|
||||
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
|
||||
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Read a value from the 64-bit HKLM view explicitly. If this runs under 32-bit
|
||||
# PowerShell the HKLM: PSDrive silently redirects to WOW6432Node, where the
|
||||
# 64-bit agent's config is not, and the upgrade would look unconfigured.
|
||||
@@ -83,25 +315,33 @@ function Get-AgentConfig {
|
||||
$baseUrl = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'BaseUrl'
|
||||
$apiKey = Get-Hklm64Value -SubKey 'SOFTWARE\GE\ShopDB' -Name 'CollectorKey'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
$pins = @()
|
||||
|
||||
# Read even when the registry is complete: serverpins lives only here.
|
||||
$settings = Join-Path $InstallDir 'appsettings.json'
|
||||
if (Test-Path $settings) {
|
||||
try {
|
||||
$json = Get-Content $settings -Raw | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) { $baseUrl = $json.baseurl }
|
||||
if ([string]::IsNullOrWhiteSpace($apiKey)) { $apiKey = $json.apikey }
|
||||
Write-Log 'read config from appsettings.json (registry unavailable or incomplete)'
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
foreach ($value in @($json.serverpins)) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$') { $pins += $pin }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "could not parse appsettings.json: $($_.Exception.Message)" 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl) -or [string]::IsNullOrWhiteSpace($apiKey)) {
|
||||
throw 'No BaseUrl/CollectorKey found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) {
|
||||
throw 'No BaseUrl found in the registry or appsettings.json. This endpoint does not look installed; run install-shopdbagent.ps1 instead.'
|
||||
}
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = $apiKey }
|
||||
return @{ BaseUrl = $baseUrl.TrimEnd('/'); ApiKey = [string]$apiKey; ServerPins = $pins }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -145,8 +385,45 @@ try {
|
||||
# TLS 1.2 explicitly: Windows PowerShell 5.1 still defaults to older
|
||||
# protocols the server will refuse.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -Uri $url -Headers @{ 'X-API-Key' = $config.ApiKey } `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
|
||||
$serverPins = @(Get-ShopDbServerPins)
|
||||
foreach ($pin in $config.ServerPins) {
|
||||
if ($serverPins -notcontains $pin) { $serverPins += $pin }
|
||||
}
|
||||
if ($serverPins.Count -gt 0) {
|
||||
if (-not (Enable-ShopDbServerPins $serverPins)) {
|
||||
throw 'ServerPins is set but the pin check could not be enabled; not downloading.'
|
||||
}
|
||||
Write-Log "server pin check on ($($serverPins.Count) pin(s))"
|
||||
}
|
||||
|
||||
$credential = Get-ShopDbEndpointCredential $config.BaseUrl
|
||||
$attempts = @()
|
||||
if ($credential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $credential }) }
|
||||
if ($config.ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $config.ApiKey }) }
|
||||
if ($attempts.Count -eq 0) {
|
||||
throw 'No endpoint credential and no CollectorKey on this endpoint; cannot authenticate the download.'
|
||||
}
|
||||
|
||||
# Credential first. A refused credential (revoked) falls back to the shared
|
||||
# key once, which the server admits again for an un-enrolled PC.
|
||||
$downloaded = $false
|
||||
foreach ($attempt in $attempts) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $url -Headers $attempt[1] `
|
||||
-OutFile $zipPath -UseBasicParsing -TimeoutSec 600
|
||||
Write-Log "authenticated with $($attempt[0])"
|
||||
$downloaded = $true
|
||||
break
|
||||
}
|
||||
catch {
|
||||
$failure = Get-ShopDbHttpFailure $_
|
||||
Write-Log "download with $($attempt[0]) failed: $($_.Exception.Message)" 'WARN'
|
||||
Write-ShopDbAuthFailure $failure
|
||||
if ($failure.Code -ne 'CREDENTIAL_INVALID') { throw }
|
||||
}
|
||||
}
|
||||
if (-not $downloaded) { throw 'Package download was refused.' }
|
||||
|
||||
$size = (Get-Item $zipPath).Length
|
||||
Write-Log "downloaded $size bytes"
|
||||
|
||||
157
plugins/endpoints/client/Get-ShopDBServerPin.ps1
Normal file
157
plugins/endpoints/client/Get-ShopDBServerPin.ps1
Normal file
@@ -0,0 +1,157 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Print the SPKI SHA-256 pin of a ShopDB server certificate, for building the
|
||||
Set-ShopDBServerPins.ps1 entry.
|
||||
|
||||
.DESCRIPTION
|
||||
The pin is the lowercase hex SHA-256 of the leaf certificate's
|
||||
SubjectPublicKeyInfo: the same value the Server Certificate settings page
|
||||
shows and the endpoint agent compares. It depends only on the key pair, so
|
||||
a certificate renewed with the same key keeps its pin.
|
||||
|
||||
Give it either a URL (the certificate the server actually presents is read
|
||||
from the TLS handshake; it is NOT trusted or validated, only hashed) or a
|
||||
certificate file (.cer, .crt, .pem, DER or PEM). Use the file form to pin a
|
||||
new certificate BEFORE it goes live on the server.
|
||||
|
||||
Reading from a URL trusts the network path at that moment. Compare the
|
||||
printed pin with the one on the Server Certificate settings page before
|
||||
rolling it out.
|
||||
|
||||
.PARAMETER Url
|
||||
https://<server>[:port]. Only the host and port are used.
|
||||
|
||||
.PARAMETER Path
|
||||
A certificate file.
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-ShopDBServerPin.ps1 -Url https://<server>
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-ShopDBServerPin.ps1 -Path .\server.crt
|
||||
#>
|
||||
[CmdletBinding(DefaultParameterSetName = 'Url')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Url', Position = 0)]
|
||||
[string]$Url,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Path')]
|
||||
[string]$Path,
|
||||
|
||||
[int]$TimeoutSec = 15
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$source = @'
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinReader
|
||||
{
|
||||
public static byte[] FetchLeafCertificate(string host, int port, int timeoutMilliseconds)
|
||||
{
|
||||
using (TcpClient client = new TcpClient())
|
||||
{
|
||||
IAsyncResult pending = client.BeginConnect(host, port, null, null);
|
||||
if (!pending.AsyncWaitHandle.WaitOne(timeoutMilliseconds))
|
||||
{
|
||||
throw new TimeoutException("Timed out connecting to " + host + ":" + port);
|
||||
}
|
||||
client.EndConnect(pending);
|
||||
client.ReceiveTimeout = timeoutMilliseconds;
|
||||
client.SendTimeout = timeoutMilliseconds;
|
||||
|
||||
byte[] captured = null;
|
||||
RemoteCertificateValidationCallback capture = delegate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (certificate != null) { captured = certificate.GetRawCertData(); }
|
||||
return true;
|
||||
};
|
||||
using (SslStream ssl = new SslStream(client.GetStream(), false, capture))
|
||||
{
|
||||
ssl.AuthenticateAsClient(host, null, SslProtocols.Tls12, false);
|
||||
}
|
||||
if (captured == null) { throw new InvalidOperationException("The server presented no certificate"); }
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
if (-not ('ShopDbServerPinReader' -as [type])) {
|
||||
Add-Type -TypeDefinition $source
|
||||
}
|
||||
|
||||
try {
|
||||
if ($PSCmdlet.ParameterSetName -eq 'Path') {
|
||||
$resolved = (Resolve-Path -LiteralPath $Path).ProviderPath
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList $resolved
|
||||
$der = $certificate.RawData
|
||||
$label = "$resolved (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
||||
} else {
|
||||
$uri = [Uri]$Url
|
||||
if ($uri.Scheme -ne 'https') { throw "The URL must be https ($Url)" }
|
||||
$der = [ShopDbServerPinReader]::FetchLeafCertificate($uri.Host, $uri.Port, $TimeoutSec * 1000)
|
||||
$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (,$der)
|
||||
$label = "$($uri.Host):$($uri.Port) (subject $($certificate.Subject), expires $($certificate.NotAfter.ToString('yyyy-MM-dd')))"
|
||||
}
|
||||
$pin = [ShopDbServerPinReader]::SpkiSha256Hex($der)
|
||||
} catch {
|
||||
Write-Error "Could not read a certificate: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Certificate: $label"
|
||||
Write-Host "Pin (SPKI SHA-256):"
|
||||
Write-Output $pin
|
||||
exit 0
|
||||
182
plugins/endpoints/client/Set-ShopDBServerPins.ps1
Normal file
182
plugins/endpoints/client/Set-ShopDBServerPins.ps1
Normal file
@@ -0,0 +1,182 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Write the ShopDB server public key pins to HKLM:\SOFTWARE\GE\ShopDB
|
||||
ServerPins, so the endpoint agent and the SYSTEM reporters trust only this
|
||||
site's server key.
|
||||
|
||||
.DESCRIPTION
|
||||
Each pin is the lowercase hex SHA-256 of the server leaf certificate's
|
||||
SubjectPublicKeyInfo. The ShopDB Server Certificate settings page shows the
|
||||
current one, and Get-ShopDBServerPin.ps1 computes one from a URL or a
|
||||
certificate file.
|
||||
|
||||
Written as REG_MULTI_SZ in BOTH registry views (64-bit and WOW6432Node),
|
||||
the same way CollectorKey is, so a 32-bit reader sees the same set.
|
||||
|
||||
Meant to run as a GE-Enforce PS1 entry under the SYSTEM task, before agent
|
||||
1.20 reaches the PC. Idempotent: the set on the PC is compared with the
|
||||
requested set first, and nothing is written when they already match, so it
|
||||
is safe every cycle.
|
||||
|
||||
ROTATING THE SERVER KEY: pass the old pin AND the new pin until every PC has
|
||||
both, switch the server certificate, then drop the old pin. A PC holding
|
||||
only the old pin refuses the new certificate.
|
||||
|
||||
.PARAMETER Pins
|
||||
One or more pins separated by commas, semicolons or spaces. Colons and case
|
||||
are ignored. Every value must be 64 hex characters or nothing is written.
|
||||
|
||||
.PARAMETER Clear
|
||||
Remove ServerPins from both views. Pinning is then off and the normal
|
||||
Windows certificate checks apply.
|
||||
|
||||
.PARAMETER LogPath
|
||||
Append a transcript here as well as writing to the pipeline.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins <pin>
|
||||
Pin one server key.
|
||||
|
||||
.EXAMPLE
|
||||
.\Set-ShopDBServerPins.ps1 -Pins "<oldpin>,<newpin>" -WhatIf
|
||||
Show what a key rotation would write without touching the registry.
|
||||
|
||||
.NOTES
|
||||
Exit codes:
|
||||
0 written, or already correct
|
||||
1 bad input or an error stopped the write
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string]$Pins,
|
||||
[switch]$Clear,
|
||||
[string]$LogPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$SubKey = 'SOFTWARE\GE\ShopDB'
|
||||
$ValueName = 'ServerPins'
|
||||
|
||||
function Log {
|
||||
param([string]$Message)
|
||||
$line = "{0} [Set-ShopDBServerPins] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
|
||||
Write-Output $line
|
||||
if ($LogPath) {
|
||||
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentPins {
|
||||
param([Microsoft.Win32.RegistryView]$View)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey)
|
||||
if (-not $key) { return @() }
|
||||
try {
|
||||
$value = $key.GetValue($ValueName)
|
||||
if ($null -eq $value) { return @() }
|
||||
return @(@($value) | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
function Test-SamePins {
|
||||
param([string[]]$Left, [string[]]$Right)
|
||||
$a = @($Left | Sort-Object -Unique)
|
||||
$b = @($Right | Sort-Object -Unique)
|
||||
if ($a.Count -ne $b.Count) { return $false }
|
||||
for ($i = 0; $i -lt $a.Count; $i++) {
|
||||
if ($a[$i] -ne $b[$i]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# --- 1. validate the request ------------------------------------------------
|
||||
|
||||
$requested = @()
|
||||
if (-not $Clear) {
|
||||
if (-not $Pins -or -not $Pins.Trim()) {
|
||||
Log 'ERROR no -Pins given (use -Clear to remove pinning)'
|
||||
exit 1
|
||||
}
|
||||
foreach ($raw in ($Pins -split '[,;\s]+')) {
|
||||
if (-not $raw) { continue }
|
||||
$pin = ($raw -replace ':', '').ToLowerInvariant()
|
||||
if ($pin -notmatch '^[0-9a-f]{64}$') {
|
||||
Log "ERROR '$raw' is not a SHA-256 pin (64 hex characters); nothing written"
|
||||
exit 1
|
||||
}
|
||||
if ($requested -notcontains $pin) { $requested += $pin }
|
||||
}
|
||||
if ($requested.Count -eq 0) {
|
||||
Log 'ERROR -Pins held no values; nothing written'
|
||||
exit 1
|
||||
}
|
||||
Log "requested $($requested.Count) pin(s): $($requested -join ', ')"
|
||||
}
|
||||
|
||||
# --- 2. write each view -----------------------------------------------------
|
||||
|
||||
$views = @(
|
||||
@{ Name = '64-bit'; View = [Microsoft.Win32.RegistryView]::Registry64 },
|
||||
@{ Name = '32-bit'; View = [Microsoft.Win32.RegistryView]::Registry32 }
|
||||
)
|
||||
|
||||
$failed = $false
|
||||
foreach ($entry in $views) {
|
||||
try {
|
||||
$current = @(Get-CurrentPins -View $entry.View)
|
||||
|
||||
if ($Clear) {
|
||||
if ($current.Count -eq 0) {
|
||||
Log "$($entry.Name): no ServerPins; nothing to do"
|
||||
continue
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", "Remove $ValueName")) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
|
||||
try {
|
||||
$key = $base.OpenSubKey($SubKey, $true)
|
||||
if ($key) {
|
||||
try { $key.DeleteValue($ValueName, $false) } finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
Log "$($entry.Name): removed ServerPins"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-SamePins -Left $current -Right $requested) {
|
||||
Log "$($entry.Name): ServerPins already correct; nothing to do"
|
||||
continue
|
||||
}
|
||||
|
||||
$action = "Set $ValueName to $($requested.Count) pin(s) (was $($current.Count))"
|
||||
if ($PSCmdlet.ShouldProcess("HKLM\$SubKey ($($entry.Name))", $action)) {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $entry.View)
|
||||
try {
|
||||
$key = $base.CreateSubKey($SubKey)
|
||||
try {
|
||||
$key.SetValue($ValueName, [string[]]$requested, [Microsoft.Win32.RegistryValueKind]::MultiString)
|
||||
} finally { $key.Dispose() }
|
||||
} finally { $base.Dispose() }
|
||||
|
||||
# Read back rather than trust SetValue.
|
||||
$after = @(Get-CurrentPins -View $entry.View)
|
||||
if (-not (Test-SamePins -Left $after -Right $requested)) {
|
||||
Log "ERROR $($entry.Name): ServerPins did not read back as written"
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
Log "$($entry.Name): wrote ServerPins ($($requested.Count) pin(s), was $($current.Count))"
|
||||
}
|
||||
} catch {
|
||||
Log "ERROR $($entry.Name): $($_.Exception.Message)"
|
||||
$failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed) { exit 1 }
|
||||
exit 0
|
||||
238
plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
Normal file
238
plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Per-PC credentials rollout
|
||||
|
||||
Runbook for moving a site's endpoint fleet from the shared collector key to
|
||||
per-PC credentials. The wire contract is `AGENT-CONTRACT.md` section 9.
|
||||
|
||||
Placeholders: `<server>` is the ShopDB base URL, `<share>` the GE-Enforce share
|
||||
root, `<pin>` a server key pin, `<pilotbay>` / `<pilothost>` the pilot PC.
|
||||
|
||||
## What changes
|
||||
|
||||
- Every endpoint gets its own secret (`sdbe_...`), stored in
|
||||
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI
|
||||
LocalMachine, readable by SYSTEM and Administrators only.
|
||||
- The server knows which asset is calling from the secret. A PC can no longer
|
||||
report as another PC.
|
||||
- The tray holds no secret. It talks to the SYSTEM agent over a local pipe.
|
||||
- The agent, the upgrade script and the SYSTEM reporters
|
||||
(`Report-AssetToShopDB.ps1`, `Report-PrintersToShopDB.ps1`) pin the server's
|
||||
public key when `ServerPins` is set.
|
||||
- The shared key keeps working for PCs that have NOT enrolled, until an admin
|
||||
turns it off. An enrolled PC is always refused the shared key
|
||||
(`401 SHARED_KEY_REFUSED`).
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Server deployed with the endpoints migration `endpoints0023credentials`
|
||||
applied (`flask plugin upgrade-all`).
|
||||
2. Settings -> Endpoints -> Server Certificate shows a **Public key pin (SPKI
|
||||
SHA-256)**. Cross-check it from a PC:
|
||||
`Get-ShopDBServerPin.ps1 -Url https://<server>`. The two must match.
|
||||
3. Agent 1.20 (and tray 1.7) uploaded as the current release under Settings ->
|
||||
Endpoints -> Upload New/Legacy Agents. Keep the previous release staged as a
|
||||
legacy build: it is the rollback.
|
||||
4. Settings -> Endpoints -> Enrollment -> Credentials: **Shared key allowed**
|
||||
on, **Auto-enroll** on (both are the defaults).
|
||||
5. The GE-Enforce manifest scope that will carry the entries below is at
|
||||
`Version` 2.2 or later, and the fleet's GE-Enforce client library
|
||||
understands `TargetHostnames` / `TargetMachineNumbers`. An older manifest or
|
||||
library ignores those lists and applies the entry to EVERY PC in scope. The
|
||||
same happens with an empty list: never publish a targeted entry with a blank
|
||||
target.
|
||||
|
||||
## Phase 1: pins on the PCs
|
||||
|
||||
Pins must be on a PC BEFORE agent 1.20 arrives there. A 1.20 agent with no pin
|
||||
falls back to plain Windows validation, which a self-signed or internal-CA
|
||||
server may fail; a pin set after the fact is not a rollout order you want to
|
||||
debug per bay.
|
||||
|
||||
Stage `plugins/endpoints/client/Set-ShopDBServerPins.ps1` on the share and add
|
||||
a PS1 entry. Pilot first:
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "Server key pin for per-PC credentials. Pilot only.",
|
||||
"Name": "ShopDB server pins",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Set-ShopDBServerPins.ps1",
|
||||
"Args": "-Pins <pin>",
|
||||
"DetectionMethod": "Always",
|
||||
"TargetMachineNumbers": ["<pilotbay>"]
|
||||
}
|
||||
```
|
||||
|
||||
Then widen by hostname list (or machine number list) in batches:
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "ShopDB server pins",
|
||||
"Type": "PS1",
|
||||
"Script": "scripts/Set-ShopDBServerPins.ps1",
|
||||
"Args": "-Pins <pin>",
|
||||
"DetectionMethod": "Always",
|
||||
"TargetHostnames": ["<host1>", "<host2>", "<host3>"]
|
||||
}
|
||||
```
|
||||
|
||||
The script compares before it writes, so `Always` costs nothing per cycle.
|
||||
|
||||
**Gate:** on the pilot, `HKLM\SOFTWARE\GE\ShopDB` and
|
||||
`HKLM\SOFTWARE\WOW6432Node\GE\ShopDB` both hold `ServerPins` (REG_MULTI_SZ)
|
||||
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.
|
||||
|
||||
## Phase 2: re-stamp the share's agent entries
|
||||
|
||||
PCs that got the agent from the share (collections manifest entries with a
|
||||
`Hash` detection on `shopdbagent.exe`) are held to that hash by GE-Enforce. If
|
||||
a store upgrade wave puts 1.20 on such a PC while the entry still carries the
|
||||
1.19 hash, GE-Enforce reinstalls 1.19 on its next cycle and the PC flips back.
|
||||
|
||||
Before any wave reaches a share-installed PC, either:
|
||||
|
||||
- re-stamp those entries to the 1.20 payload and its `DetectionValue` SHA-256
|
||||
(and keep the `ProductCode`/version guards the entries already have), or
|
||||
- remove the entries for the PCs the wave will cover.
|
||||
|
||||
Agent entries stay targeted. Illustrative shape for the pilot (keep your
|
||||
existing entry's type, installer and guards; what matters is the hash and a
|
||||
non-empty target):
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "ShopDB endpoint agent 1.20",
|
||||
"Type": "PS1",
|
||||
"Script": "apps/shopdbagent/1.20.0/install-shopdbagent.ps1",
|
||||
"DetectionMethod": "Hash",
|
||||
"DetectionPath": "C:\\Program Files\\ShopDB\\EndpointAgent\\shopdbagent.exe",
|
||||
"DetectionValue": "<sha256 of the 1.20 shopdbagent.exe>",
|
||||
"TargetHostnames": ["<pilothost>"]
|
||||
}
|
||||
```
|
||||
|
||||
**Gate:** the manifest preview for the pilot shows the 1.20 hash, and no entry
|
||||
for the same file with a blank target list exists in any scope that reaches it.
|
||||
|
||||
## Phase 3: pilot one PC
|
||||
|
||||
1. Enrollment -> Migration: find `<pilothost>`. It should be **Ready**. If it is
|
||||
**Blocked**, the Blockers column lists every reason; fix those first.
|
||||
2. Tick it, **Upgrade selected**.
|
||||
3. Watch the row: **Upgrading**, then **Enrolled** within a few minutes of the
|
||||
new agent starting. The agent enrolls on its own with the shared key it
|
||||
already holds.
|
||||
|
||||
**Gate before any wave:**
|
||||
|
||||
- Credentials tab shows the PC enrolled, `enrolledvia` = `auto`, and a
|
||||
`lastusedat` that keeps moving.
|
||||
- The PC's Fleet row keeps reporting; commands (Collect now) still work.
|
||||
- The tray shows alerts and Send Status works (it now goes through the pipe).
|
||||
- `report-asset-*.log` and `report-printers-*.log` show `RESPONSE (credential)`.
|
||||
- Nothing reinstalled the old agent after two GE-Enforce cycles.
|
||||
|
||||
## Phase 4: waves
|
||||
|
||||
Migration tab -> **Upgrade next 25**. The confirm dialog states how many PCs get
|
||||
agent 1.20. The server re-checks readiness per PC and packages the agent once
|
||||
per wave; PCs that stopped being ready are returned as skipped.
|
||||
|
||||
Between waves, in the Waves list:
|
||||
|
||||
- wait until the wave shows **Done** (every PC enrolled or failed),
|
||||
- investigate every **failed** PC before the next wave,
|
||||
- check nothing moved from Enrolled back to Upgrading or Ready (a share entry
|
||||
reinstalling the old agent, see phase 2).
|
||||
|
||||
Repeat until **Ready** is 0.
|
||||
|
||||
## Phase 5: stragglers and new PCs
|
||||
|
||||
- **Blocked:** each blocker names its fix. `offline`: bring the PC back or wait.
|
||||
`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).
|
||||
- **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.
|
||||
- **Pending:** the agent asked but auto-enroll could not decide (serial
|
||||
mismatch, duplicate or unknown hostname, already enrolled, or auto-enroll
|
||||
off). Credentials tab -> Requests -> **Approve**; the picker defaults to the
|
||||
matched computer, search to choose another. The PC collects its credential on
|
||||
its next retry (within 15 minutes). **Reject** anything you do not recognise.
|
||||
- **Newly imaged PCs:** imaging installs 1.20 with the shared key, and the agent
|
||||
auto-enrolls on first start. After the shared key is turned off (phase 6) a
|
||||
new PC cannot auto-enroll: its request lands in Pending and an admin approves
|
||||
it in the UI. That is the steady-state path for new PCs.
|
||||
|
||||
## Phase 6: cutover
|
||||
|
||||
**Gate:** Credentials tab **Not enrolled** is 0, or every remaining PC is one
|
||||
you have decided may stop reporting. Remember that turning the shared key off
|
||||
also refuses it on `/api/collector/computers` and `/api/collector/printers`, so
|
||||
a PC with reporter scripts but no enrolled agent goes silent.
|
||||
|
||||
1. Turn **Shared key allowed** off. The confirm shows how many PCs are not
|
||||
enrolled; type `OFF`.
|
||||
2. Watch Fleet for an hour. A PC that went quiet is either unenrolled (turn the
|
||||
shared key back on, fix it, try again) or failed pinning.
|
||||
3. Remove the shared key from every place it lives (below).
|
||||
4. Rotate the old key so any copy you missed is dead: Settings -> Endpoints ->
|
||||
**Regenerate key** (and rotate the collector token the reporters used, if it
|
||||
was a separate token). With the shared key off nothing on the floor needs
|
||||
the new key: approved enroll requests are collected without it. Store it
|
||||
the way the key page describes and keep it out of every file below. A
|
||||
fleet rollback after this point (see Rollback) needs the new key pushed to
|
||||
the PCs again.
|
||||
|
||||
### Every place the shared key lives
|
||||
|
||||
| Where | Put there by | How to clear at cutover |
|
||||
|---|---|---|
|
||||
| GE-Enforce manifest entry `Args` (`-ApiKey ...`) | whoever authored the entry | Remove the argument from the entry and publish. Search every scope for the key prefix. |
|
||||
| `HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, both registry views | agent `install.ps1`; `Set-ShopdbCollectorKey.ps1` from `<share>\common\configs\shopdb-collector.txt` | First retire the `Set-ShopdbCollectorKey.ps1` entry (or it rewrites the value every cycle), then a targeted PS1 entry that deletes `CollectorKey` in both views. |
|
||||
| `<share>\common\configs\shopdb-collector.txt` | share staging | Delete the file after the entry above is retired. |
|
||||
| `appsettings.json` `apikey` in the agent install folder | agent package / hand edits | Targeted PS1 entry that sets `apikey` to an empty string. The agent never needs it once enrolled. |
|
||||
| Generated `install.ps1` default `-CollectorKey` (Build site agent downloads) | Settings -> Endpoints -> Build site agent | Delete every kept copy (share, USB, work folders). Rebuild after rotation only if you still need the package. |
|
||||
| Display kiosk `shopdb-key.txt` | display imaging | Confirm what the kiosk uses it for before clearing; a kiosk has no endpoint agent, so anything it reports with the shared key stops at cutover. Delete the file once that is settled. |
|
||||
|
||||
The agent never deletes the shared key itself: other SYSTEM scripts may still
|
||||
read it until this table is done.
|
||||
|
||||
## Rollback
|
||||
|
||||
**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
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## Quick reference
|
||||
|
||||
- Pin of the live server: Server Certificate settings page, or
|
||||
`Get-ShopDBServerPin.ps1 -Url https://<server>`.
|
||||
- Pin of a certificate file: `Get-ShopDBServerPin.ps1 -Path <file>`.
|
||||
- Agent log of enrollment: `%ProgramData%\ShopDB\EndpointAgent\logs`.
|
||||
- Reporter logs: `C:\Logs\Shopfloor\report-asset-*.log`,
|
||||
`report-printers-*.log`. `SHARED_KEY_REFUSED` there means the PC is enrolled
|
||||
or the shared key is off and the reporter could not read the credential;
|
||||
`CREDENTIAL_INVALID` means the PC was un-enrolled and the agent is
|
||||
re-enrolling.
|
||||
@@ -43,6 +43,18 @@ export default [
|
||||
requiresPermission: 'endpoints.runscript',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
},
|
||||
// Readable by endpoints.fleet holders so they can watch a migration; every
|
||||
// write on the page is admin-only and re-checked server-side.
|
||||
{
|
||||
path: 'settings/endpoints/enrollment',
|
||||
name: 'endpoints-enrollment-settings',
|
||||
component: () => import('./views/EnrollmentSettings.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresPermission: 'endpoints.fleet',
|
||||
plugin: 'endpoints'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
989
plugins/endpoints/frontend/views/EnrollmentSettings.vue
Normal file
989
plugins/endpoints/frontend/views/EnrollmentSettings.vue
Normal file
@@ -0,0 +1,989 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Enrollment</h2>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Each endpoint agent authenticates with its own credential, readable only
|
||||
by SYSTEM on that PC. Agents from {{ target }} onward enroll themselves
|
||||
with the shared key they already hold; older agents move over by
|
||||
upgrading. Use Migration to move the fleet, and Credentials to approve
|
||||
requests, un-enroll a PC, and retire the shared key.
|
||||
</p>
|
||||
|
||||
<div class="enroll-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="enroll-tab"
|
||||
:class="{ active: tab === 'migration' }"
|
||||
@click="tab = 'migration'"
|
||||
>
|
||||
Migration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="enroll-tab"
|
||||
:class="{ active: tab === 'credentials' }"
|
||||
@click="tab = 'credentials'"
|
||||
>
|
||||
Credentials
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================================================================
|
||||
Migration
|
||||
=============================================================== -->
|
||||
<section v-if="tab === 'migration'" class="migration-tab">
|
||||
<div v-if="migrationError" class="load-error">{{ migrationError }}</div>
|
||||
|
||||
<div class="enroll-tiles">
|
||||
<button
|
||||
v-for="tile in migrationTiles"
|
||||
:key="tile.state"
|
||||
type="button"
|
||||
class="enroll-tile"
|
||||
:class="[tile.tone, { on: migrationState === tile.state }]"
|
||||
@click="toggleMigrationState(tile.state)"
|
||||
>
|
||||
<span class="n" :data-count="tile.state">{{ migrationCounts[tile.state] || 0 }}</span>
|
||||
<span class="l">{{ tile.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: enrolledPercent + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-text">
|
||||
{{ migrationCounts.enrolled || 0 }} of {{ migrationTotal }} enrolled
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="migrationSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search hostname..."
|
||||
@input="debouncedMigrationSearch"
|
||||
/>
|
||||
<select v-model="migrationState" class="form-control enroll-filter" @change="reloadMigration">
|
||||
<option value="">All states</option>
|
||||
<option v-for="tile in migrationTiles" :key="tile.state" :value="tile.state">
|
||||
{{ tile.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="isadmin" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selected.length }} selected</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm upgrade-selected"
|
||||
:disabled="busy || !selected.length"
|
||||
@click="upgradeSelected"
|
||||
>
|
||||
Upgrade selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm upgrade-next"
|
||||
:disabled="busy || !(migrationCounts.ready > 0)"
|
||||
@click="upgradeNext"
|
||||
>
|
||||
Upgrade next {{ waveSize }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="migrationLoading" class="loading">Loading...</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="isadmin" class="select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allReadySelected"
|
||||
:disabled="!readyRows.length"
|
||||
title="Select every ready PC on this page"
|
||||
@change="toggleAllReady"
|
||||
/>
|
||||
</th>
|
||||
<th>Hostname</th>
|
||||
<th>State</th>
|
||||
<th>Agent version</th>
|
||||
<th>Last reported</th>
|
||||
<th>Blockers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!migrationRows.length">
|
||||
<td :colspan="isadmin ? 6 : 5" class="empty-cell">No computers match.</td>
|
||||
</tr>
|
||||
<tr v-for="row in migrationRows" :key="row.assetid" class="migration-row">
|
||||
<td v-if="isadmin" class="select-cell">
|
||||
<input
|
||||
v-if="row.state === 'ready'"
|
||||
v-model="selected"
|
||||
type="checkbox"
|
||||
:value="row.assetid"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ row.hostname || '-' }}</td>
|
||||
<td><span class="state-chip" :class="row.state">{{ stateLabel(row.state) }}</span></td>
|
||||
<td>{{ row.agentversion || '-' }}</td>
|
||||
<td>{{ formatDate(row.lastreportedat) }}</td>
|
||||
<td class="blockers">{{ (row.blockers || []).join(', ') || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationBar
|
||||
:page="migrationPage"
|
||||
:total-pages="migrationTotalPages"
|
||||
@update:page="goToMigrationPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Waves</h3>
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Wave</th>
|
||||
<th>Created</th>
|
||||
<th>By</th>
|
||||
<th>Size</th>
|
||||
<th>Queued</th>
|
||||
<th>Enrolled</th>
|
||||
<th>Failed</th>
|
||||
<th>Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!waves.length">
|
||||
<td colspan="8" class="empty-cell">No waves yet.</td>
|
||||
</tr>
|
||||
<tr v-for="wave in waves" :key="wave.waveid">
|
||||
<td>{{ wave.waveid }}</td>
|
||||
<td>{{ formatDate(wave.createdat) }}</td>
|
||||
<td>{{ wave.createdby || '-' }}</td>
|
||||
<td>{{ wave.size }}</td>
|
||||
<td>{{ wave.queued }}</td>
|
||||
<td>{{ wave.enrolled }}</td>
|
||||
<td>{{ wave.failed }}</td>
|
||||
<td>
|
||||
<div class="progress-track small">
|
||||
<div class="progress-fill" :style="{ width: wavePercent(wave) + '%' }"></div>
|
||||
</div>
|
||||
<span class="field-hint">{{ wave.done ? 'Done' : 'In progress' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================================================================
|
||||
Credentials
|
||||
=============================================================== -->
|
||||
<section v-else class="credentials-tab">
|
||||
<div v-if="enrollmentError" class="load-error">{{ enrollmentError }}</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<h3>Settings</h3>
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-sharedkey"
|
||||
:checked="enrollmentSettings.sharedkeyallowed"
|
||||
:disabled="!isadmin || busy"
|
||||
@change="onSharedKeyToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Shared key allowed.</strong> PCs that have not enrolled may
|
||||
still report with the fleet shared key. An enrolled PC is always
|
||||
refused the shared key. Turn this off once every PC is enrolled.
|
||||
</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle-autoenroll"
|
||||
:checked="enrollmentSettings.autoenroll"
|
||||
:disabled="!isadmin || busy"
|
||||
@change="onAutoEnrollToggle($event)"
|
||||
/>
|
||||
<span>
|
||||
<strong>Auto-enroll.</strong> Issue a credential without approval
|
||||
when the hostname matches exactly one computer and the serial number
|
||||
on record matches. Anything else waits below for an admin.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="enroll-tiles">
|
||||
<div class="enroll-tile static ok">
|
||||
<span class="n" data-count="enrolled">{{ enrollmentCounts.enrolled || 0 }}</span>
|
||||
<span class="l">Enrolled</span>
|
||||
</div>
|
||||
<div class="enroll-tile static warn">
|
||||
<span class="n" data-count="notenrolled">{{ enrollmentCounts.notenrolled || 0 }}</span>
|
||||
<span class="l">Not enrolled</span>
|
||||
</div>
|
||||
<div class="enroll-tile static">
|
||||
<span class="n" data-count="pending">{{ enrollmentCounts.pending || 0 }}</span>
|
||||
<span class="l">Pending requests</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Requests</h3>
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Serial</th>
|
||||
<th>Device id</th>
|
||||
<th>Reason</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
<th>Last seen</th>
|
||||
<th v-if="isadmin"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!requests.length">
|
||||
<td :colspan="isadmin ? 8 : 7" class="empty-cell">No enrollment requests.</td>
|
||||
</tr>
|
||||
<template v-for="request in requests" :key="request.requestid">
|
||||
<tr class="request-row">
|
||||
<td>{{ request.hostname }}</td>
|
||||
<td>{{ request.serialnumber || '-' }}</td>
|
||||
<td>{{ request.deviceid || '-' }}</td>
|
||||
<td>{{ request.reason || '-' }}</td>
|
||||
<td>{{ request.status }}</td>
|
||||
<td>{{ request.sourceip || '-' }}</td>
|
||||
<td>{{ formatDate(request.lastseenat) }}</td>
|
||||
<td v-if="isadmin" class="action-cell">
|
||||
<template v-if="request.status === 'pending'">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm approve-open"
|
||||
:disabled="busy"
|
||||
@click="openApprove(request)"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm reject"
|
||||
:disabled="busy"
|
||||
@click="reject(request)"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="approving && approving.requestid === request.requestid" class="approve-row">
|
||||
<td :colspan="isadmin ? 8 : 7">
|
||||
<div class="approve-form">
|
||||
<label class="field-label">Issue the credential for computer</label>
|
||||
<input
|
||||
v-model="approving.search"
|
||||
type="text"
|
||||
class="form-control asset-search"
|
||||
placeholder="Search hostname or serial..."
|
||||
@input="debouncedAssetSearch"
|
||||
/>
|
||||
<select v-model="approving.assetid" class="form-control asset-picker">
|
||||
<option :value="null">Choose a computer</option>
|
||||
<option
|
||||
v-for="option in approving.options"
|
||||
:key="option.assetid"
|
||||
:value="option.assetid"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm approve-confirm"
|
||||
:disabled="busy || !approving.assetid"
|
||||
@click="approve"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="approving = null">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
The PC receives its credential the next time it asks,
|
||||
which is within 15 minutes. Only the PC that made this
|
||||
request can collect it.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Computers</h3>
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="enrollmentSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search hostname..."
|
||||
@input="debouncedEnrollmentSearch"
|
||||
/>
|
||||
<select v-model="enrollmentState" class="form-control enroll-filter" @change="reloadEnrollment">
|
||||
<option value="">All states</option>
|
||||
<option value="enrolled">Enrolled</option>
|
||||
<option value="notenrolled">Not enrolled</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="revoked">Revoked</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div v-if="enrollmentLoading" class="loading">Loading...</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Serial</th>
|
||||
<th>State</th>
|
||||
<th>Credential</th>
|
||||
<th>Issued</th>
|
||||
<th>Last used</th>
|
||||
<th>Via</th>
|
||||
<th v-if="isadmin"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationBar
|
||||
:page="enrollmentPage"
|
||||
:total-pages="enrollmentTotalPages"
|
||||
@update:page="goToEnrollmentPage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import api, { computersApi } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
|
||||
const PERPAGE = 50
|
||||
const POLL_MS = 30000
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast()
|
||||
// UI convenience only; every write is re-checked server-side.
|
||||
const isadmin = computed(() => authStore.isAdmin)
|
||||
|
||||
const tab = ref('migration')
|
||||
const busy = ref(false)
|
||||
const waveSize = 25
|
||||
|
||||
// ---- Migration --------------------------------------------------------
|
||||
|
||||
const migrationTiles = [
|
||||
{ state: 'enrolled', label: 'Enrolled', tone: 'ok' },
|
||||
{ state: 'ready', label: 'Ready', tone: 'ok' },
|
||||
{ state: 'blocked', label: 'Blocked', tone: 'bad' },
|
||||
{ state: 'upgrading', label: 'Upgrading', tone: '' },
|
||||
{ state: 'pending', label: 'Pending', tone: 'warn' },
|
||||
{ state: 'notreporting', label: 'Not reporting', tone: '' }
|
||||
]
|
||||
|
||||
const target = ref('1.20.0')
|
||||
const migrationCounts = ref({})
|
||||
const migrationRows = ref([])
|
||||
const waves = ref([])
|
||||
const migrationPage = ref(1)
|
||||
const migrationTotalPages = ref(1)
|
||||
const migrationState = ref('')
|
||||
const migrationSearch = ref('')
|
||||
const migrationLoading = ref(false)
|
||||
const migrationError = ref('')
|
||||
const selected = ref([])
|
||||
|
||||
const migrationTotal = computed(() =>
|
||||
migrationTiles.reduce((sum, tile) => sum + (Number(migrationCounts.value[tile.state]) || 0), 0))
|
||||
|
||||
const enrolledPercent = computed(() => {
|
||||
if (!migrationTotal.value) return 0
|
||||
return Math.round(100 * (Number(migrationCounts.value.enrolled) || 0) / migrationTotal.value)
|
||||
})
|
||||
|
||||
const readyRows = computed(() => migrationRows.value.filter(row => row.state === 'ready'))
|
||||
const allReadySelected = computed(() =>
|
||||
readyRows.value.length > 0 && readyRows.value.every(row => selected.value.includes(row.assetid)))
|
||||
|
||||
function toggleAllReady(event) {
|
||||
selected.value = event.target.checked ? readyRows.value.map(row => row.assetid) : []
|
||||
}
|
||||
|
||||
function toggleMigrationState(state) {
|
||||
migrationState.value = migrationState.value === state ? '' : state
|
||||
reloadMigration()
|
||||
}
|
||||
|
||||
async function loadMigration({ quiet = false } = {}) {
|
||||
if (!quiet) migrationLoading.value = true
|
||||
try {
|
||||
const params = { page: migrationPage.value, perpage: PERPAGE }
|
||||
if (migrationState.value) params.state = migrationState.value
|
||||
if (migrationSearch.value) params.search = migrationSearch.value
|
||||
const response = await api.get('/endpoints/enrollment/migration', { params })
|
||||
const data = response.data.data || {}
|
||||
if (data.target) target.value = data.target
|
||||
migrationCounts.value = data.counts || {}
|
||||
migrationRows.value = data.computers || []
|
||||
waves.value = data.waves || []
|
||||
migrationTotalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
// Drop selections that are no longer ready (queued by a wave, went offline).
|
||||
const readyIds = new Set(readyRows.value.map(row => row.assetid))
|
||||
selected.value = selected.value.filter(id => readyIds.has(id))
|
||||
migrationError.value = ''
|
||||
} catch (error) {
|
||||
migrationError.value = apiError(error, 'Could not load migration status')
|
||||
} finally {
|
||||
migrationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadMigration() {
|
||||
migrationPage.value = 1
|
||||
loadMigration()
|
||||
}
|
||||
|
||||
function goToMigrationPage(page) {
|
||||
migrationPage.value = page
|
||||
loadMigration()
|
||||
}
|
||||
|
||||
let migrationSearchTimer = null
|
||||
function debouncedMigrationSearch() {
|
||||
clearTimeout(migrationSearchTimer)
|
||||
migrationSearchTimer = setTimeout(reloadMigration, 300)
|
||||
}
|
||||
|
||||
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.'
|
||||
}
|
||||
|
||||
async function startWave(body, count) {
|
||||
if (!count) return
|
||||
if (!confirm(waveConfirmText(count))) return
|
||||
busy.value = true
|
||||
try {
|
||||
const response = await api.post('/endpoints/enrollment/migration/wave', body)
|
||||
const data = response.data.data || {}
|
||||
const queued = (data.queued || []).length
|
||||
const skipped = data.skipped || []
|
||||
if (queued) toast.success(`Wave ${data.waveid}: upgrade queued for ${queued} PC(s)`)
|
||||
if (skipped.length) {
|
||||
toast.info(`${skipped.length} skipped: `
|
||||
+ skipped.slice(0, 5).map(item => `${item.assetid} (${item.reason})`).join(', '))
|
||||
}
|
||||
selected.value = []
|
||||
await loadMigration({ quiet: true })
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not start the wave'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function upgradeSelected() {
|
||||
const assetids = [...selected.value]
|
||||
return startWave({ assetids }, assetids.length)
|
||||
}
|
||||
|
||||
function upgradeNext() {
|
||||
const count = Math.min(waveSize, Number(migrationCounts.value.ready) || 0)
|
||||
return startWave({ next: waveSize }, count)
|
||||
}
|
||||
|
||||
function wavePercent(wave) {
|
||||
if (!wave.size) return 0
|
||||
return Math.round(100 * ((Number(wave.enrolled) || 0) + (Number(wave.failed) || 0)) / wave.size)
|
||||
}
|
||||
|
||||
// ---- Credentials ------------------------------------------------------
|
||||
|
||||
const enrollmentSettings = ref({ sharedkeyallowed: true, autoenroll: true })
|
||||
const enrollmentCounts = ref({})
|
||||
const credentialRows = ref([])
|
||||
const requests = ref([])
|
||||
const enrollmentPage = ref(1)
|
||||
const enrollmentTotalPages = ref(1)
|
||||
const enrollmentState = ref('')
|
||||
const enrollmentSearch = ref('')
|
||||
const enrollmentLoading = ref(false)
|
||||
const enrollmentError = ref('')
|
||||
const approving = ref(null)
|
||||
|
||||
async function loadEnrollment({ quiet = false } = {}) {
|
||||
if (!quiet) enrollmentLoading.value = true
|
||||
try {
|
||||
const params = { page: enrollmentPage.value, perpage: PERPAGE }
|
||||
if (enrollmentState.value) params.state = enrollmentState.value
|
||||
if (enrollmentSearch.value) params.search = enrollmentSearch.value
|
||||
const response = await api.get('/endpoints/enrollment', { params })
|
||||
const data = response.data.data || {}
|
||||
enrollmentSettings.value = {
|
||||
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
|
||||
autoenroll: data.settings?.autoenroll !== false
|
||||
}
|
||||
enrollmentCounts.value = data.counts || {}
|
||||
credentialRows.value = data.computers || []
|
||||
requests.value = data.requests || []
|
||||
enrollmentTotalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
enrollmentError.value = ''
|
||||
} catch (error) {
|
||||
enrollmentError.value = apiError(error, 'Could not load enrollment')
|
||||
} finally {
|
||||
enrollmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadEnrollment() {
|
||||
enrollmentPage.value = 1
|
||||
loadEnrollment()
|
||||
}
|
||||
|
||||
function goToEnrollmentPage(page) {
|
||||
enrollmentPage.value = page
|
||||
loadEnrollment()
|
||||
}
|
||||
|
||||
let enrollmentSearchTimer = null
|
||||
function debouncedEnrollmentSearch() {
|
||||
clearTimeout(enrollmentSearchTimer)
|
||||
enrollmentSearchTimer = setTimeout(reloadEnrollment, 300)
|
||||
}
|
||||
|
||||
async function saveSettings(next) {
|
||||
busy.value = true
|
||||
try {
|
||||
await api.put('/endpoints/enrollment/settings', next)
|
||||
toast.success('Enrollment settings saved')
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not save enrollment settings'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
await loadEnrollment({ quiet: true })
|
||||
}
|
||||
}
|
||||
|
||||
function onSharedKeyToggle(event) {
|
||||
const turningOn = event.target.checked
|
||||
if (!turningOn) {
|
||||
const notenrolled = Number(enrollmentCounts.value.notenrolled) || 0
|
||||
const answer = prompt(
|
||||
'Turn OFF the shared key?\n\n'
|
||||
+ `${notenrolled} PC(s) are not enrolled yet. Every one of them stops `
|
||||
+ 'reporting, stops receiving commands, and cannot enroll on its own until '
|
||||
+ 'the shared key is turned back on or an admin approves its request.\n\n'
|
||||
+ 'Type OFF to confirm.')
|
||||
if (!answer || answer.trim().toUpperCase() !== 'OFF') {
|
||||
event.target.checked = true
|
||||
return
|
||||
}
|
||||
}
|
||||
return saveSettings({
|
||||
sharedkeyallowed: turningOn,
|
||||
autoenroll: enrollmentSettings.value.autoenroll
|
||||
})
|
||||
}
|
||||
|
||||
function onAutoEnrollToggle(event) {
|
||||
return saveSettings({
|
||||
sharedkeyallowed: enrollmentSettings.value.sharedkeyallowed,
|
||||
autoenroll: event.target.checked
|
||||
})
|
||||
}
|
||||
|
||||
function openApprove(request) {
|
||||
const options = []
|
||||
if (request.matchedassetid) {
|
||||
options.push({ assetid: request.matchedassetid, label: `${request.hostname} (matched)` })
|
||||
}
|
||||
approving.value = {
|
||||
requestid: request.requestid,
|
||||
hostname: request.hostname,
|
||||
assetid: request.matchedassetid || null,
|
||||
search: '',
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
let assetSearchTimer = null
|
||||
function debouncedAssetSearch() {
|
||||
clearTimeout(assetSearchTimer)
|
||||
assetSearchTimer = setTimeout(searchAssets, 300)
|
||||
}
|
||||
|
||||
async function searchAssets() {
|
||||
const current = approving.value
|
||||
if (!current || !current.search) return
|
||||
try {
|
||||
const response = await computersApi.list({ search: current.search, perpage: 20 })
|
||||
const found = (response.data.data || []).map(item => ({
|
||||
assetid: item.assetid,
|
||||
label: `${item.computer?.hostname || item.name || item.assetid}`
|
||||
+ (item.serialnumber ? ` (${item.serialnumber})` : '')
|
||||
}))
|
||||
const keep = current.options.filter(option => option.assetid === current.assetid)
|
||||
const merged = [...keep]
|
||||
found.forEach(option => {
|
||||
if (!merged.some(existing => existing.assetid === option.assetid)) merged.push(option)
|
||||
})
|
||||
current.options = merged
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not search computers'))
|
||||
}
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
const current = approving.value
|
||||
if (!current || !current.assetid) return
|
||||
const option = current.options.find(item => item.assetid === current.assetid)
|
||||
if (!confirm(`Approve the request from ${current.hostname} and issue a credential for `
|
||||
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
|
||||
{ assetid: current.assetid })
|
||||
toast.success(`Approved ${current.hostname}`)
|
||||
approving.value = null
|
||||
await loadEnrollment({ quiet: true })
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not approve the request'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reject(request) {
|
||||
if (!confirm(`Reject the enrollment request from ${request.hostname}?`)) return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post(`/endpoints/enrollment/requests/${request.requestid}/reject`)
|
||||
toast.success(`Rejected ${request.hostname}`)
|
||||
await loadEnrollment({ quiet: true })
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not reject the request'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row) {
|
||||
const fallback = 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
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post(`/endpoints/enrollment/assets/${row.assetid}/revoke`)
|
||||
toast.success(`Un-enrolled ${row.hostname}`)
|
||||
await loadEnrollment({ quiet: true })
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not un-enroll'))
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shared -----------------------------------------------------------
|
||||
|
||||
function stateLabel(state) {
|
||||
return {
|
||||
enrolled: 'Enrolled',
|
||||
notenrolled: 'Not enrolled',
|
||||
pending: 'Pending',
|
||||
revoked: 'Revoked',
|
||||
upgrading: 'Upgrading',
|
||||
ready: 'Ready',
|
||||
blocked: 'Blocked',
|
||||
notreporting: 'Not reporting'
|
||||
}[state] || state || '-'
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
// Waves progress on their own; poll while this tab is open.
|
||||
let pollTimer = null
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(() => {
|
||||
if (tab.value === 'migration' && !busy.value) loadMigration({ quiet: true })
|
||||
}, POLL_MS)
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
watch(tab, value => {
|
||||
if (value === 'migration') loadMigration()
|
||||
else loadEnrollment()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadMigration()
|
||||
loadEnrollment({ quiet: true })
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
clearTimeout(migrationSearchTimer)
|
||||
clearTimeout(enrollmentSearchTimer)
|
||||
clearTimeout(assetSearchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.enroll-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.enroll-tab {
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.enroll-tab:hover { color: var(--text); }
|
||||
|
||||
.enroll-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.load-error {
|
||||
padding: 0.75rem 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.enroll-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.enroll-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.enroll-tile.static { cursor: default; }
|
||||
|
||||
.enroll-tile:not(.static):hover,
|
||||
.enroll-tile.on { border-color: var(--primary); }
|
||||
|
||||
.enroll-tile .n {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.enroll-tile .l {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.enroll-tile.ok .n { color: var(--success-dark); }
|
||||
.enroll-tile.warn .n { color: var(--warning-dark); }
|
||||
.enroll-tile.bad .n { color: var(--danger); }
|
||||
|
||||
[data-theme="dark"] .enroll-tile.ok .n { color: var(--success); }
|
||||
[data-theme="dark"] .enroll-tile.warn .n { color: var(--warning); }
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-track.small {
|
||||
height: 6px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enroll-filter { max-width: 200px; }
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
|
||||
|
||||
.select-cell { width: 1%; }
|
||||
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state-chip.enrolled,
|
||||
.state-chip.ready { color: var(--success-dark); }
|
||||
.state-chip.pending,
|
||||
.state-chip.upgrading { color: var(--warning-dark); }
|
||||
.state-chip.blocked,
|
||||
.state-chip.revoked { color: var(--danger); }
|
||||
|
||||
[data-theme="dark"] .state-chip.enrolled,
|
||||
[data-theme="dark"] .state-chip.ready { color: var(--success); }
|
||||
[data-theme="dark"] .state-chip.pending,
|
||||
[data-theme="dark"] .state-chip.upgrading { color: var(--warning); }
|
||||
|
||||
.blockers {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.section-title { margin: 1.5rem 0 0.5rem; }
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-cell .btn + .btn { margin-left: 0.35rem; }
|
||||
|
||||
.approve-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.approve-form .form-control { max-width: 260px; }
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -66,6 +66,24 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group spki-pin">
|
||||
<label>Public key pin (SPKI SHA-256)</label>
|
||||
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
|
||||
<p v-else class="field-hint">Not reported by this server.</p>
|
||||
<button
|
||||
v-if="cert.spkisha256"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="copyPin"
|
||||
>
|
||||
{{ pinCopied ? 'Copied' : 'Copy pin' }}
|
||||
</button>
|
||||
<p class="field-hint">
|
||||
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
|
||||
(-Pins) so agents 1.20 and later trust only this server's key.
|
||||
It survives a renewal that keeps the same private key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Issuer</label>
|
||||
<p class="field-hint mono">{{ cert.issuer }}</p>
|
||||
@@ -134,7 +152,11 @@
|
||||
<div class="warning-banner" v-if="cert">
|
||||
<strong>Warning:</strong> Generating a new certificate will replace the current one.
|
||||
Agents using ServerCertThumbprint pinning will reject connections until you update
|
||||
their appsettings.json with the new thumbprint.
|
||||
their appsettings.json with the new thumbprint. A new certificate also has a new
|
||||
key, so agents with ServerPins set will reject it until the pin entry carries the
|
||||
new pin as well. Generating here gives no window to do that; to rotate without an
|
||||
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
|
||||
it to the pin entry alongside the old one, and upload that certificate below.
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
@@ -278,6 +300,7 @@ const message = ref('')
|
||||
const error = ref('')
|
||||
const cert = ref(null)
|
||||
const thumbprintCopied = ref(false)
|
||||
const pinCopied = ref(false)
|
||||
|
||||
const generateForm = ref({
|
||||
cn: 'localhost',
|
||||
@@ -405,6 +428,14 @@ function copyThumbprint() {
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function copyPin() {
|
||||
navigator.clipboard.writeText(cert.value.spkisha256)
|
||||
pinCopied.value = true
|
||||
setTimeout(() => {
|
||||
pinCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A'
|
||||
const date = new Date(dateString)
|
||||
|
||||
200
plugins/endpoints/frontend/views/enrollmentSettings.spec.js
Normal file
200
plugins/endpoints/frontend/views/enrollmentSettings.spec.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Enrollment settings page (per-PC credentials).
|
||||
*
|
||||
* Pins the wire shapes the page sends, because the server contract is exact:
|
||||
* a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen
|
||||
* assetid, and turning the shared key off must tell the admin how many PCs are
|
||||
* not enrolled yet before anything is sent.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
computersApi: { list: vi.fn() }
|
||||
}))
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({ isAdmin: true, hasPermission: () => true })
|
||||
}))
|
||||
vi.mock('@/composables/toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })
|
||||
}))
|
||||
vi.mock('@/components/PaginationBar.vue', () => ({
|
||||
default: { name: 'PaginationBar', template: '<div />' }
|
||||
}))
|
||||
|
||||
import api, { computersApi } from '@/api'
|
||||
import EnrollmentSettings from './EnrollmentSettings.vue'
|
||||
|
||||
function migrationPayload() {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
target: '1.20.0',
|
||||
counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 },
|
||||
computers: [
|
||||
{ assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] },
|
||||
{ assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked',
|
||||
blockers: ['offline', 'no serial on record'] }
|
||||
],
|
||||
waves: [
|
||||
{ waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false }
|
||||
]
|
||||
},
|
||||
meta: { pagination: { totalpages: 1 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enrollmentPayload() {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
settings: { sharedkeyallowed: true, autoenroll: true },
|
||||
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' }
|
||||
],
|
||||
requests: [
|
||||
{ requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch',
|
||||
status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null }
|
||||
]
|
||||
},
|
||||
meta: { pagination: { totalpages: 1 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
api.get.mockImplementation(path => Promise.resolve(
|
||||
path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload()))
|
||||
api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } })
|
||||
api.put.mockResolvedValue({ data: { data: {} } })
|
||||
const wrapper = mount(EnrollmentSettings)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function openCredentials(wrapper) {
|
||||
const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials')
|
||||
await tab.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('EnrollmentSettings', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.put.mockReset()
|
||||
computersApi.list.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders the migration counts and enrolled progress', async () => {
|
||||
const wrapper = await render()
|
||||
expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40')
|
||||
expect(wrapper.find('[data-count="ready"]').text()).toBe('30')
|
||||
expect(wrapper.find('[data-count="blocked"]').text()).toBe('5')
|
||||
expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20')
|
||||
expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled')
|
||||
})
|
||||
|
||||
it('shows every blocker for a blocked PC', async () => {
|
||||
const wrapper = await render()
|
||||
const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED'))
|
||||
expect(row.find('.blockers').text()).toBe('offline, no serial on record')
|
||||
})
|
||||
|
||||
it('only offers a checkbox on ready rows', async () => {
|
||||
const wrapper = await render()
|
||||
const rows = wrapper.findAll('.migration-row')
|
||||
expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true)
|
||||
expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
await wrapper.find('.upgrade-next').trigger('click')
|
||||
await flushPromises()
|
||||
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(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 })
|
||||
})
|
||||
|
||||
it('sends nothing when the wave confirm is declined', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => false))
|
||||
const wrapper = await render()
|
||||
await wrapper.find('.upgrade-next').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts the selected assetids for Upgrade selected', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.find('.upgrade-selected').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] })
|
||||
})
|
||||
|
||||
it('approves a request with the matched asset by default', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.approve-open').trigger('click')
|
||||
expect(wrapper.find('.asset-picker').element.value).toBe('77')
|
||||
await wrapper.find('.approve-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
|
||||
})
|
||||
|
||||
it('rejects a request after confirm', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.reject').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject')
|
||||
})
|
||||
|
||||
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')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
|
||||
})
|
||||
|
||||
it('shows the not-enrolled count before turning the shared key off', async () => {
|
||||
const promptSpy = vi.fn(() => 'OFF')
|
||||
vi.stubGlobal('prompt', promptSpy)
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
await wrapper.find('.toggle-sharedkey').setValue(false)
|
||||
await flushPromises()
|
||||
expect(promptSpy).toHaveBeenCalledTimes(1)
|
||||
expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/)
|
||||
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
|
||||
{ sharedkeyallowed: false, autoenroll: true })
|
||||
})
|
||||
|
||||
it('leaves the shared key on when the confirmation is not typed', async () => {
|
||||
vi.stubGlobal('prompt', vi.fn(() => 'yes'))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
const toggle = wrapper.find('.toggle-sharedkey')
|
||||
await toggle.setValue(false)
|
||||
await flushPromises()
|
||||
expect(api.put).not.toHaveBeenCalled()
|
||||
expect(toggle.element.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -30,11 +30,19 @@
|
||||
# lost its printers.
|
||||
#
|
||||
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
|
||||
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
|
||||
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
|
||||
# CollectorKey (the same secret store Report-AssetToShopDB.ps1 uses; provisioned
|
||||
# at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake
|
||||
# the key into the manifest JSON on the share.
|
||||
# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+)
|
||||
# has enrolled this PC, its per-PC credential is read from the agent's
|
||||
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
|
||||
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
|
||||
# collector.ingest key is sent as the X-API-Key header, read from
|
||||
# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (the same secret store
|
||||
# Report-AssetToShopDB.ps1 uses; provisioned at imaging), or overridden via the
|
||||
# manifest entry's Args -ApiKey. Never bake the key into the manifest JSON on
|
||||
# the share.
|
||||
#
|
||||
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
|
||||
# must match one of them or nothing is sent. See
|
||||
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
|
||||
#
|
||||
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
|
||||
# the SYSTEM task. Always exits 0 so a printer problem never fails an
|
||||
@@ -77,6 +85,227 @@ function Log([string]$msg) {
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-PC credential and server pin. Kept in step with the same block in
|
||||
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
|
||||
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
|
||||
#
|
||||
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
|
||||
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
|
||||
# Administrators. When present it is sent as X-Endpoint-Credential and the
|
||||
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
|
||||
#
|
||||
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
|
||||
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
|
||||
# is set the TLS handshake is accepted only if the leaf key matches one.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
|
||||
|
||||
$ShopDbPinSource = @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
public static class ShopDbServerPinValidator
|
||||
{
|
||||
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
public static string LastPresented;
|
||||
|
||||
public static void Install(string[] pins)
|
||||
{
|
||||
Pins.Clear();
|
||||
foreach (string pin in pins)
|
||||
{
|
||||
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
|
||||
}
|
||||
ServicePointManager.ServerCertificateValidationCallback = Validate;
|
||||
}
|
||||
|
||||
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
|
||||
{
|
||||
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
|
||||
if (certificate == null) { return false; }
|
||||
try
|
||||
{
|
||||
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
LastPresented = null;
|
||||
return false;
|
||||
}
|
||||
return Pins.Contains(LastPresented);
|
||||
}
|
||||
|
||||
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
|
||||
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
||||
public static string SpkiSha256Hex(byte[] der)
|
||||
{
|
||||
int length;
|
||||
int position = EnterSequence(der, 0, out length);
|
||||
position = EnterSequence(der, position, out length);
|
||||
if (der[position] == 0xA0) { position = Skip(der, position); }
|
||||
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
|
||||
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
|
||||
int end = Skip(der, position);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(der, position, end - position);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ReadLength(byte[] der, int position, out int length)
|
||||
{
|
||||
int first = der[position];
|
||||
if (first < 0x80) { length = first; return position + 1; }
|
||||
int count = first & 0x7F;
|
||||
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
|
||||
length = 0;
|
||||
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
|
||||
return position + 1 + count;
|
||||
}
|
||||
|
||||
private static int EnterSequence(byte[] der, int position, out int length)
|
||||
{
|
||||
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
|
||||
return ReadLength(der, position + 1, out length);
|
||||
}
|
||||
|
||||
private static int Skip(byte[] der, int position)
|
||||
{
|
||||
int length;
|
||||
int content = ReadLength(der, position + 1, out length);
|
||||
return content + length;
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
function Get-ShopDbServerPins {
|
||||
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
|
||||
$pins = @()
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
|
||||
try {
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
|
||||
try {
|
||||
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
|
||||
if ($key) {
|
||||
try {
|
||||
foreach ($value in @($key.GetValue('ServerPins'))) {
|
||||
if ($null -eq $value) { continue }
|
||||
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
|
||||
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
|
||||
}
|
||||
} finally { $key.Dispose() }
|
||||
}
|
||||
} finally { $base.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
return ,$pins
|
||||
}
|
||||
|
||||
function Enable-ShopDbServerPins([string[]]$Pins) {
|
||||
# True when pinning is active. False means pins are set but could not be
|
||||
# enforced; the caller must not send anything then.
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
|
||||
# would silently not happen.
|
||||
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
if (-not ('ShopDbServerPinValidator' -as [type])) {
|
||||
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
|
||||
}
|
||||
[ShopDbServerPinValidator]::Install($Pins)
|
||||
return $true
|
||||
} catch {
|
||||
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
|
||||
# Empty when there is none or it cannot be used for this URL.
|
||||
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security -ErrorAction Stop
|
||||
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
|
||||
} catch {
|
||||
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
|
||||
return ''
|
||||
}
|
||||
$credential = [string]$record.credential
|
||||
if (-not $credential.StartsWith('sdbe_')) {
|
||||
Log 'WARN endpoint credential file has no usable credential; ignoring it'
|
||||
return ''
|
||||
}
|
||||
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
|
||||
return ''
|
||||
}
|
||||
# Never hand a credential to a server other than the one that issued it.
|
||||
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
|
||||
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
|
||||
return ''
|
||||
}
|
||||
return $credential
|
||||
}
|
||||
|
||||
function Get-ShopDbHttpFailure($ErrorRecord) {
|
||||
# Status and error envelope code (data.error.code) from a failed request.
|
||||
$status = 0
|
||||
$code = ''
|
||||
$text = ''
|
||||
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
|
||||
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
|
||||
$text = $ErrorRecord.ErrorDetails.Message
|
||||
} else {
|
||||
try {
|
||||
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
|
||||
$text = $reader.ReadToEnd()
|
||||
$reader.Dispose()
|
||||
} catch {}
|
||||
}
|
||||
if ($text) {
|
||||
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 }
|
||||
}
|
||||
|
||||
function Write-ShopDbAuthFailure($Failure) {
|
||||
switch ($Failure.Code) {
|
||||
'CREDENTIAL_INVALID' {
|
||||
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
|
||||
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
|
||||
}
|
||||
'SHARED_KEY_REFUSED' {
|
||||
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
|
||||
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
|
||||
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
|
||||
}
|
||||
'ASSET_MISMATCH' {
|
||||
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
|
||||
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
|
||||
}
|
||||
default {
|
||||
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
|
||||
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')
|
||||
|
||||
function Get-ShopdbRegValue([string]$name) {
|
||||
@@ -107,9 +336,22 @@ if (-not $ApiUrl -and -not $WhatIfOnly) {
|
||||
|
||||
# collector key from the GE-Enforce secret store when not passed via Args.
|
||||
if (-not $ApiKey) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' }
|
||||
if (-not $ApiKey -and -not $WhatIfOnly) {
|
||||
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||
exit 0
|
||||
$EndpointCredential = ''
|
||||
if (-not $WhatIfOnly) {
|
||||
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
|
||||
if (-not $EndpointCredential -and -not $ApiKey) {
|
||||
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$serverPins = Get-ShopDbServerPins
|
||||
if ($serverPins.Count -gt 0) {
|
||||
if (-not (Enable-ShopDbServerPins $serverPins)) {
|
||||
Log 'ERROR refusing to report without the server pin check. Skipping.'
|
||||
exit 0
|
||||
}
|
||||
Log "server pin check on ($($serverPins.Count) pin(s))"
|
||||
}
|
||||
}
|
||||
|
||||
# Queues that are not devices: the Windows-supplied virtual printers plus the
|
||||
@@ -301,14 +543,27 @@ if ($WhatIfOnly) {
|
||||
}
|
||||
|
||||
Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count)
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||
-ContentType 'application/json' `
|
||||
-Headers @{ 'X-API-Key' = $ApiKey } `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4))
|
||||
} catch {
|
||||
Log "ERROR POST failed: $($_.Exception.Message)"
|
||||
# Credential first; the shared key only when there is no credential, or when
|
||||
# the server has just refused the credential (revoked: the shared key is
|
||||
# admitted again for this PC until the agent re-enrolls). Never both at once.
|
||||
$attempts = @()
|
||||
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
|
||||
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
|
||||
|
||||
foreach ($attempt in $attempts) {
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||
-ContentType 'application/json' `
|
||||
-Headers $attempt[1] `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($response | ConvertTo-Json -Compress -Depth 4))
|
||||
break
|
||||
} catch {
|
||||
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
|
||||
$failure = Get-ShopDbHttpFailure $_
|
||||
Write-ShopDbAuthFailure $failure
|
||||
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
|
||||
}
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user