Make rollback, first upgrades and pin changes work as tested on Windows
Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
This commit is contained in:
@@ -41,8 +41,11 @@ public sealed class AgentHttpFactory
|
||||
// Caller owns and disposes the client (it disposes the handler chain).
|
||||
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
|
||||
{
|
||||
// Lifetime cap: pooled TLS sessions were validated against the pins of
|
||||
// their handshake, so recycle them for a pin change to reach new ones.
|
||||
var sockets = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, cert, _, errors)
|
||||
@@ -57,11 +60,9 @@ public sealed class AgentHttpFactory
|
||||
// server keep-alive, or a reused dead socket yields "unexpected
|
||||
// EOF or 0 bytes from the transport stream" (1.6.2).
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
|
||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
||||
break;
|
||||
case AgentHttpChannel.FleetAlert:
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
|
||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -176,40 +177,94 @@ public sealed record AgentServerError(string Code, string Message)
|
||||
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
|
||||
// configured only a matching leaf is accepted and chain/name errors are ignored
|
||||
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None.
|
||||
// Registry pins re-read at most every RefreshInterval on the handshake path, so a
|
||||
// GE-Enforce fix or a "next" rotation pin lands without a service restart.
|
||||
public sealed class ServerPinSet
|
||||
{
|
||||
public const string RegistryValue = "ServerPins";
|
||||
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HashSet<string> spkiPins;
|
||||
private readonly string? legacyThumbprint;
|
||||
private readonly ILogger logger;
|
||||
|
||||
private ServerPinSet(HashSet<string> spkiPins, string? legacyThumbprint, ILogger logger)
|
||||
private sealed record Snapshot(HashSet<string> SpkiPins, string RawRegistry)
|
||||
{
|
||||
this.spkiPins = spkiPins;
|
||||
this.legacyThumbprint = legacyThumbprint;
|
||||
this.logger = logger;
|
||||
public string Signature => string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public bool IsPinned => spkiPins.Count > 0 || legacyThumbprint is not null;
|
||||
private readonly HashSet<string> configPins;
|
||||
private readonly string? legacyThumbprint;
|
||||
private readonly ILogger logger;
|
||||
private readonly Func<bool, List<string>?> readRegistry;
|
||||
private readonly object refreshLock = new();
|
||||
private volatile Snapshot current;
|
||||
private long nextRefreshTicks;
|
||||
|
||||
private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger,
|
||||
Func<bool, List<string>?> readRegistry)
|
||||
{
|
||||
this.configPins = configPins;
|
||||
this.legacyThumbprint = legacyThumbprint;
|
||||
this.logger = logger;
|
||||
this.readRegistry = readRegistry;
|
||||
current = Build(readRegistry(false) ?? new List<string>());
|
||||
nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds;
|
||||
}
|
||||
|
||||
public bool IsPinned => Current().SpkiPins.Count > 0 || legacyThumbprint is not null;
|
||||
|
||||
public static ServerPinSet Load(AgentConfig config, ILogger logger)
|
||||
{
|
||||
var pins = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var pin in config.ServerPins ?? new List<string>())
|
||||
AddPin(pins, pin, "appsettings.json", logger);
|
||||
foreach (var pin in ReadRegistryPins(logger))
|
||||
AddPin(pins, pin, "registry", logger);
|
||||
|
||||
string? thumbprint = null;
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
|
||||
|
||||
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
|
||||
var count = set.current.SpkiPins.Count;
|
||||
logger.LogInformation(
|
||||
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||
pins.Count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||
pins.Count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
return new ServerPinSet(pins, thumbprint, logger);
|
||||
count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||
count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
return set;
|
||||
}
|
||||
|
||||
// Cheap tick compare per handshake; registry read only when the window lapsed.
|
||||
private Snapshot Current()
|
||||
{
|
||||
if (Environment.TickCount64 < Interlocked.Read(ref nextRefreshTicks))
|
||||
return current;
|
||||
lock (refreshLock)
|
||||
{
|
||||
if (Environment.TickCount64 < nextRefreshTicks)
|
||||
return current;
|
||||
var raw = readRegistry(true);
|
||||
Interlocked.Exchange(ref nextRefreshTicks,
|
||||
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
|
||||
var previous = current;
|
||||
if (raw is null || string.Join("|", raw) == previous.RawRegistry)
|
||||
return previous;
|
||||
|
||||
var next = Build(raw);
|
||||
current = next;
|
||||
if (next.Signature != previous.Signature)
|
||||
logger.LogInformation(
|
||||
"Server pins changed: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
|
||||
next.SpkiPins.Count,
|
||||
string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])),
|
||||
legacyThumbprint is null ? "" : " + legacy thumbprint",
|
||||
next.SpkiPins.Count > 0 || legacyThumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed-pin warnings only fire when the raw registry text changes.
|
||||
private Snapshot Build(List<string> raw)
|
||||
{
|
||||
var pins = new HashSet<string>(configPins, StringComparer.Ordinal);
|
||||
foreach (var pin in raw)
|
||||
AddPin(pins, pin, "registry", logger);
|
||||
return new Snapshot(pins, string.Join("|", raw));
|
||||
}
|
||||
|
||||
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
|
||||
@@ -220,7 +275,8 @@ public sealed class ServerPinSet
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsPinned)
|
||||
var snapshot = Current();
|
||||
if (snapshot.SpkiPins.Count == 0 && legacyThumbprint is null)
|
||||
{
|
||||
if (errors == SslPolicyErrors.None)
|
||||
return true;
|
||||
@@ -235,7 +291,7 @@ public sealed class ServerPinSet
|
||||
try
|
||||
{
|
||||
var spki = SpkiSha256(leaf);
|
||||
if (spkiPins.Contains(spki))
|
||||
if (snapshot.SpkiPins.Contains(spki))
|
||||
return true;
|
||||
if (legacyThumbprint is not null
|
||||
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -270,7 +326,9 @@ public sealed class ServerPinSet
|
||||
pins.Add(pin);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadRegistryPins(ILogger logger)
|
||||
// Startup keeps whatever views read. A refresh returns null on any failed view
|
||||
// so a transient denial cannot silently drop pins (and the server with them).
|
||||
private static List<string>? ReadRegistryPins(ILogger logger, bool refresh)
|
||||
{
|
||||
var found = new List<string>();
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
@@ -291,6 +349,11 @@ public sealed class ServerPinSet
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (refresh)
|
||||
{
|
||||
logger.LogDebug(error, "Registry re-read of {Value} in {View} view failed; keeping pins", RegistryValue, view);
|
||||
return null;
|
||||
}
|
||||
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user