Make the one-line install survive Defender, and approval leave a PC that works
All three found testing the bootstrap installer on Windows. - The published command used the irm-pipe-iex form, which Microsoft Defender detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time, on a default install. The command now downloads the script to a file, runs it, and removes it in a finally block, which Defender does not object to. The UI also offers the script as a plain download for a tech who would rather save it. - Approving a request only linked the asset, so a PC whose pending reason was "unknown hostname" - which is every PC installed this way - kept getting 403 on every call afterwards, because its computer record had no hostname to match the credential against. Approval now stamps the hostname when the record has none, refuses with a 409 naming both when the record has a different one, and overwrites only when the admin confirms. The Credentials tab offers that confirmation. - A hostname that resolves to no record now says so instead of reading as an impostor, while still refusing the request. - While waiting for approval the agent retried on the 15-minute telemetry cadence, so a PC approved immediately enrolled a quarter of an hour later and the installer always ended on "still pending". It now retries about every minute for the first ten minutes, then every five to thirty minutes, then every fifteen, with jitter. A rate limit or server error still uses the slower backoff.
This commit is contained in:
@@ -13,9 +13,10 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// unenrolled or pending PC keeps reporting.
|
||||
//
|
||||
// 201 enrolled store credential, swap provider, idle until woken
|
||||
// 202 pending retry every 15 min +/- 2 min (admin approval, serial conflict)
|
||||
// 202 pending PendingSchedule: 1 min, then 5 min, then 15 min
|
||||
// 404 server predates enrollment: idle, retry every 6 h
|
||||
// other back off 1, 5, 15 min (cap)
|
||||
// other back off 1, 5, 15 min (cap); a 429 or 5xx lands here, so a
|
||||
// rate limit drops out of the fast pending phase for that try
|
||||
//
|
||||
// Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on
|
||||
// SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute.
|
||||
@@ -23,8 +24,16 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
public const string DeviceIdValue = "DeviceId";
|
||||
|
||||
private static readonly TimeSpan PendingRetry = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan PendingJitter = TimeSpan.FromMinutes(2);
|
||||
// Retry while pending, by how long this request has been pending. An admin
|
||||
// usually approves within a couple of minutes and a tech is standing there,
|
||||
// so ask often at first. Jitter is added, never subtracted, so MinWakeInterval
|
||||
// cannot clip it back into lockstep across a room of PCs.
|
||||
private static readonly (TimeSpan Waited, TimeSpan Retry, TimeSpan Jitter)[] PendingSchedule =
|
||||
{
|
||||
(TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(8)),
|
||||
(TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(20)),
|
||||
(TimeSpan.MaxValue, TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(2)),
|
||||
};
|
||||
private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan[] ErrorBackoff =
|
||||
{
|
||||
@@ -85,6 +94,10 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
int failures = 0;
|
||||
var lastAttempt = DateTime.MinValue;
|
||||
// Start of the current pending run; reset on a new request id so a
|
||||
// reject-and-ask-again (or a revoke) gets the fast phase over again.
|
||||
DateTime? pendingSince = null;
|
||||
int? pendingRequest = null;
|
||||
using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll);
|
||||
|
||||
try
|
||||
@@ -93,6 +106,8 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
if (credentials.Current is not null)
|
||||
{
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
firstAttempt.TrySetResult();
|
||||
await wake.WaitAsync(stoppingToken);
|
||||
continue;
|
||||
@@ -103,7 +118,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
await Task.Delay(MinWakeInterval - sinceLast, stoppingToken);
|
||||
|
||||
lastAttempt = DateTime.UtcNow;
|
||||
var outcome = await TryEnrollAsync(client, stoppingToken);
|
||||
var (outcome, requestId) = await TryEnrollAsync(client, stoppingToken);
|
||||
firstAttempt.TrySetResult();
|
||||
|
||||
TimeSpan delay;
|
||||
@@ -111,14 +126,24 @@ public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
case EnrollOutcome.Enrolled:
|
||||
failures = 0;
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
continue;
|
||||
case EnrollOutcome.Pending:
|
||||
failures = 0;
|
||||
delay = PendingRetry + TimeSpan.FromSeconds(
|
||||
(jitter.NextDouble() * 2 - 1) * PendingJitter.TotalSeconds);
|
||||
if (pendingSince is null
|
||||
|| (requestId is not null && requestId != pendingRequest))
|
||||
{
|
||||
pendingSince = DateTime.UtcNow;
|
||||
pendingRequest = requestId;
|
||||
}
|
||||
delay = PendingDelay(DateTime.UtcNow - pendingSince.Value);
|
||||
logger.LogDebug("Next enrollment attempt in {Delay}.", delay);
|
||||
break;
|
||||
case EnrollOutcome.Unsupported:
|
||||
failures = 0;
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
delay = UnsupportedRetry;
|
||||
break;
|
||||
default:
|
||||
@@ -142,14 +167,26 @@ public sealed class EnrollmentService : BackgroundService
|
||||
|
||||
private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed }
|
||||
|
||||
private async Task<EnrollOutcome> TryEnrollAsync(HttpClient client, CancellationToken token)
|
||||
private TimeSpan PendingDelay(TimeSpan waited)
|
||||
{
|
||||
foreach (var step in PendingSchedule)
|
||||
{
|
||||
if (waited < step.Waited)
|
||||
return step.Retry
|
||||
+ TimeSpan.FromSeconds(jitter.NextDouble() * step.Jitter.TotalSeconds);
|
||||
}
|
||||
return PendingSchedule[^1].Retry;
|
||||
}
|
||||
|
||||
private async Task<(EnrollOutcome Outcome, int? RequestId)> TryEnrollAsync(
|
||||
HttpClient client, CancellationToken token)
|
||||
{
|
||||
var hostname = Environment.MachineName;
|
||||
var serial = ReadBiosSerial();
|
||||
if (string.IsNullOrWhiteSpace(serial))
|
||||
{
|
||||
logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS.");
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
string nonce;
|
||||
@@ -160,7 +197,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Enrollment skipped: could not read or create enroll.json.");
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
var request = new
|
||||
@@ -183,7 +220,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
logger.LogInformation(
|
||||
"Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.",
|
||||
UnsupportedRetry);
|
||||
return EnrollOutcome.Unsupported;
|
||||
return (EnrollOutcome.Unsupported, null);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Created)
|
||||
@@ -193,7 +230,7 @@ public sealed class EnrollmentService : BackgroundService
|
||||
if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogError("Enrollment returned 201 without a usable credential.");
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
var credential = new AgentCredential
|
||||
@@ -214,18 +251,19 @@ public sealed class EnrollmentService : BackgroundService
|
||||
"Enrolled as asset {AssetId} but could not store credential {Prefix}. " +
|
||||
"Revoke it in ShopDB so this PC can enroll again.",
|
||||
credential.AssetId, credential.Prefix);
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
return EnrollOutcome.Enrolled;
|
||||
return (EnrollOutcome.Enrolled, null);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
var result = ParseResult(body);
|
||||
var requestId = result.GetInt("requestid");
|
||||
logger.LogInformation(
|
||||
"Enrollment pending (request {RequestId}): {Reason}.",
|
||||
result.GetInt("requestid"), result.GetString("reason") ?? "no reason given");
|
||||
return EnrollOutcome.Pending;
|
||||
requestId, result.GetString("reason") ?? "no reason given");
|
||||
return (EnrollOutcome.Pending, requestId);
|
||||
}
|
||||
|
||||
var refusal = AgentServerError.TryParse(body);
|
||||
@@ -234,19 +272,20 @@ public sealed class EnrollmentService : BackgroundService
|
||||
logger.LogError(
|
||||
"Enrollment refused: the approved request belongs to a different enroll nonce. " +
|
||||
"An admin must reject it so this PC can request again.");
|
||||
return EnrollOutcome.Pending;
|
||||
// No request id: keeps the current pending run and its schedule.
|
||||
return (EnrollOutcome.Pending, null);
|
||||
}
|
||||
|
||||
logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}",
|
||||
(int)response.StatusCode, refusal?.Code, refusal?.Message);
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is HttpRequestException or TaskCanceledException or JsonException
|
||||
&& !token.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("Enrollment request failed: {Message}", error.Message);
|
||||
return EnrollOutcome.Failed;
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user