Authenticate the agent with its own credential, pin the server, and take secrets out of the tray

Agent 1.20.0, tray 1.7.0. Ship together.

Agent:
- Every HTTP client, including script and package downloads, is built by one
  AgentHttpFactory. Server pins (SPKI SHA-256, current and next) come from
  HKLM ServerPins or appsettings serverpins; with a pin set only a matching
  key is trusted, otherwise the normal chain check applies.
- EnrollmentService enrolls once using the shared key the PC already has,
  stores the credential DPAPI-protected in a SYSTEM-only directory, and swaps
  to it without a restart. Pending requests retry every 15 minutes; an old
  server without the enroll route leaves the agent on the shared key. A
  revoked credential is deleted and the PC re-enrolls.
- A credential is only ever sent to the server that issued it.
- Telemetry posts to /api/endpoints/collect when using a credential.
- A named pipe lets the interactive user ask the agent to send status, send a
  report, list alerts and acknowledge one. The report sidecar and screenshot
  must sit in the caller's own profile and not be a link.
- --once and --report run as a user become pipe clients.
- appsettings.json is restricted to SYSTEM and Administrators once the pipe is
  up.
- Removed client certificate renewal and expiry monitoring.

Tray:
- Holds no key and reads no agent configuration. Alerts, acknowledgements,
  Send Status and Report an issue all go through the agent's pipe.
- An acknowledgement is remembered only when the agent confirms it.
- When the agent is not running, the menu says so and alert polling retries
  quietly.
This commit is contained in:
cproudlock
2026-09-15 12:04:03 -04:00
parent 7480e4fe3a
commit 35c17d2ea2
22 changed files with 2072 additions and 986 deletions

View File

@@ -13,19 +13,17 @@ public sealed class AgentConfig
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = ""; [JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900; [JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Optional client certificate thumbprint for mTLS authentication. When set, // Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no
// the agent attaches the certificate from LocalMachine\My to HTTPS requests. // separators). Still honoured next to ServerPins; breaks on every cert
// The server validates the fingerprint against its database. Empty = use // renewal, so prefer an SPKI pin. See AgentHttpFactory.
// X-API-Key only (dual-auth during rollout).
[JsonPropertyName("clientcertthumbprint")] public string? ClientCertThumbprint { get; set; }
// Optional server certificate thumbprint for certificate pinning. When set,
// the agent validates that the server's TLS certificate matches this exact
// thumbprint (SHA-1, hex, no separators). Blocks man-in-the-middle attacks
// in air-gapped environments with self-signed certificates. Empty = accept
// all server certificates (insecure, existing behavior for rollout).
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; } [JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
// Server public key pins: lowercase hex SHA-256 of the leaf certificate's
// SubjectPublicKeyInfo. Merged with HKLM\SOFTWARE\GE\ShopDB ServerPins
// (REG_MULTI_SZ). Any pin configured = the leaf key must match one; chain
// and name errors are then ignored. None = normal Windows chain validation.
[JsonPropertyName("serverpins")] public List<string> ServerPins { get; set; } = new();
// Jitter spreads a fleet's reports so they do not arrive in lockstep // Jitter spreads a fleet's reports so they do not arrive in lockstep
// (thundering herd). Actual delay = cadence +/- up to jitterseconds. // (thundering herd). Actual delay = cadence +/- up to jitterseconds.
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60; [JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
@@ -159,8 +157,12 @@ public sealed class AgentConfig
public List<string> ApprovedTaskPaths { get; set; } = new(); public List<string> ApprovedTaskPaths { get; set; } = new();
public bool IsUsable() public bool IsUsable()
=> !string.IsNullOrWhiteSpace(BaseUrl) => !string.IsNullOrWhiteSpace(BaseUrl) && HasSharedKey();
&& !string.IsNullOrWhiteSpace(ApiKey)
// True when a real shared collector key is provisioned (not the shipped
// placeholder). An enrolled agent runs without one.
public bool HasSharedKey()
=> !string.IsNullOrWhiteSpace(ApiKey)
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY"; && ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
// Validates the BaseUrl scheme before any request is made. A plaintext // Validates the BaseUrl scheme before any request is made. A plaintext

View File

@@ -0,0 +1,269 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// This PC's own endpoint credential, issued once by POST /api/endpoints/enroll.
// Readable only by SYSTEM and Administrators (DPAPI LocalMachine inside a
// protected directory). The JSON key names are a contract: SYSTEM PowerShell
// (upgrade-shopdbagent.ps1, the reporters) reads the same file with
// [System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine').
public sealed class AgentCredential
{
[JsonPropertyName("credential")] public string Credential { get; set; } = "";
[JsonPropertyName("assetid")] public int AssetId { get; set; }
[JsonPropertyName("issuedat")] public string IssuedAt { get; set; } = "";
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
// First 12 chars, the same prefix the server shows. Never log more.
[JsonIgnore]
public string Prefix => Credential.Length <= 12 ? Credential : Credential[..12];
}
// Why the server refused an agent request. Parsed from the error envelope.
public static class AgentAuthErrorCodes
{
public const string CredentialInvalid = "CREDENTIAL_INVALID";
public const string SharedKeyRefused = "SHARED_KEY_REFUSED";
public const string AssetMismatch = "ASSET_MISMATCH";
}
// Current auth material for outbound requests. Swaps at runtime: enrollment
// sets a credential, a CREDENTIAL_INVALID clears it, neither needs a restart.
public interface ICredentialProvider
{
// Null = not enrolled (or the stored credential belongs to another server).
AgentCredential? Current { get; }
// Shared fleet collector key, null when not provisioned.
string? SharedKey { get; }
// Raised after Current changes (set or cleared).
event EventHandler? Changed;
// Raised when the enrollment service should run now.
event EventHandler? EnrollmentNeeded;
// Called by the auth handler for every 401/403 carrying an auth code.
// sentCredential is the credential the failed request carried, or null
// when it went out on the shared key.
void ReportRejected(string code, string? sentCredential);
}
public sealed class AgentCredentialProvider : ICredentialProvider
{
private readonly object gate = new();
private readonly CredentialStore? store;
private readonly ILogger logger;
private AgentCredential? current;
public AgentCredentialProvider(AgentConfig config, CredentialStore? store, ILogger logger)
{
this.store = store;
this.logger = logger;
SharedKey = config.HasSharedKey() ? config.ApiKey : null;
var loaded = store?.TryLoad();
if (loaded is not null && !SameServer(loaded.BaseUrl, config.BaseUrl))
{
// Never send a credential to a server that did not issue it. Kept on
// disk so pointing BaseUrl back restores it; a new enroll overwrites.
logger.LogWarning(
"Stored credential {Prefix} was issued by {Issuer}, not {BaseUrl}; not using it.",
loaded.Prefix, loaded.BaseUrl, config.BaseUrl);
loaded = null;
}
current = loaded;
if (current is not null)
logger.LogInformation("Using endpoint credential {Prefix} (asset {AssetId}).",
current.Prefix, current.AssetId);
}
public AgentCredential? Current
{
get { lock (gate) return current; }
}
public string? SharedKey { get; }
public event EventHandler? Changed;
public event EventHandler? EnrollmentNeeded;
// Persist then swap. Throws when the store cannot be written: a credential
// held only in memory is lost at restart and the server will not reissue.
public void Set(AgentCredential credential)
{
store?.Save(credential);
lock (gate) current = credential;
logger.LogInformation("Endpoint credential {Prefix} stored (asset {AssetId}).",
credential.Prefix, credential.AssetId);
Changed?.Invoke(this, EventArgs.Empty);
}
public void ReportRejected(string code, string? sentCredential)
{
if (code == AgentAuthErrorCodes.CredentialInvalid && sentCredential is not null)
{
bool cleared = false;
lock (gate)
{
// Only the credential that was refused. A late response for an
// old credential must not wipe a freshly issued one.
if (current is not null && current.Credential == sentCredential)
{
logger.LogWarning(
"Server refused credential {Prefix} (revoked or unknown); deleting it and re-enrolling.",
current.Prefix);
current = null;
cleared = true;
}
}
if (cleared)
{
try
{
store?.Delete();
}
catch (Exception error)
{
logger.LogError(error, "Could not delete the refused credential file.");
}
Changed?.Invoke(this, EventArgs.Empty);
EnrollmentNeeded?.Invoke(this, EventArgs.Empty);
}
}
else if (code == AgentAuthErrorCodes.SharedKeyRefused && sentCredential is null && Current is null)
{
logger.LogWarning("Server refused the shared key for this PC; requesting enrollment.");
EnrollmentNeeded?.Invoke(this, EventArgs.Empty);
}
else if (code == AgentAuthErrorCodes.AssetMismatch)
{
logger.LogError(
"Server reported ASSET_MISMATCH: this PC's credential belongs to a different asset than the request named.");
}
}
private static bool SameServer(string issued, string configured)
=> string.Equals(Normalize(issued), Normalize(configured), StringComparison.OrdinalIgnoreCase);
private static string Normalize(string url) => (url ?? "").Trim().TrimEnd('/');
}
// credential.bin and enroll.json under %ProgramData%\ShopDB\EndpointAgent\credential.
public sealed class CredentialStore
{
public const string DirectoryName = "credential";
public const string CredentialFileName = "credential.bin";
public const string EnrollFileName = "enroll.json";
private const int MaxFileBytes = 64 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly ILogger logger;
public CredentialStore(ILogger logger)
{
this.logger = logger;
}
private static string StoreDirectory => ProtectedFileSystem.EnsureDirectory(DirectoryName);
public AgentCredential? TryLoad()
{
try
{
var path = Path.Combine(StoreDirectory, CredentialFileName);
if (ProtectedFileSystem.TryGetAttributes(path) is null)
return null;
var blob = ReadAllBytesNoFollow(path);
var json = ProtectedData.Unprotect(blob, null, DataProtectionScope.LocalMachine);
var credential = JsonSerializer.Deserialize<AgentCredential>(json, JsonOptions);
if (credential is null || !credential.Credential.StartsWith("sdbe_", StringComparison.Ordinal))
{
logger.LogWarning("Stored credential file is not a usable credential; ignoring it.");
return null;
}
return credential;
}
catch (Exception error)
{
logger.LogError(error, "Could not read the stored endpoint credential; running unenrolled.");
return null;
}
}
public void Save(AgentCredential credential)
{
var dir = StoreDirectory;
var json = JsonSerializer.SerializeToUtf8Bytes(credential);
var blob = ProtectedData.Protect(json, null, DataProtectionScope.LocalMachine);
var target = Path.Combine(dir, CredentialFileName);
var temp = Path.Combine(dir, CredentialFileName + ".new");
using (var file = ProtectedFileSystem.CreateFile(temp))
{
file.Write(blob);
file.Flush(flushToDisk: true);
}
// Moved file keeps its protected DACL.
File.Move(temp, target, overwrite: true);
}
public void Delete()
=> ProtectedFileSystem.DeleteFileNoFollow(Path.Combine(StoreDirectory, CredentialFileName));
// The enroll nonce is generated once and kept for the life of the PC, so
// an approved request can only be claimed by the agent that made it.
public string GetOrCreateEnrollNonce()
{
var path = Path.Combine(StoreDirectory, EnrollFileName);
if (ProtectedFileSystem.TryGetAttributes(path) is not null)
{
try
{
using var doc = JsonDocument.Parse(ReadAllBytesNoFollow(path));
if (doc.RootElement.TryGetProperty("enrollnonce", out var value)
&& value.GetString() is string nonce && nonce.Length >= 32)
return nonce;
logger.LogWarning("enroll.json has no usable nonce; generating a new one.");
}
catch (Exception error) when (error is JsonException or IOException or UnauthorizedAccessException)
{
logger.LogWarning(error, "enroll.json unreadable; generating a new nonce.");
}
}
var fresh = Base64Url(RandomNumberGenerator.GetBytes(32));
var body = JsonSerializer.SerializeToUtf8Bytes(new
{
enrollnonce = fresh,
createdat = DateTime.UtcNow.ToString("o"),
});
using (var file = ProtectedFileSystem.CreateFile(path))
{
file.Write(body);
file.Flush(flushToDisk: true);
}
return fresh;
}
internal static string Base64Url(byte[] bytes)
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] ReadAllBytesNoFollow(string path)
{
using var stream = ProtectedFileSystem.OpenReadNoFollow(path);
if (stream.Length > MaxFileBytes)
throw new InvalidDataException($"{path} is larger than expected");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return buffer.ToArray();
}
}

View File

@@ -0,0 +1,299 @@
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// What an HttpClient is for. Decides pooling, timeout and auth.
public enum AgentHttpChannel
{
Collector,
Command,
FleetAlert,
Download,
Enroll,
}
// The ONLY place the agent builds an HttpClient or a handler. Every channel gets
// the same server certificate check (ServerPinSet) and the same per-request auth
// (AgentAuthHandler), so a new channel cannot forget either.
public sealed class AgentHttpFactory
{
private readonly AgentConfig config;
private readonly ICredentialProvider credentials;
private readonly ILogger logger;
private readonly ServerPinSet pins;
public AgentHttpFactory(AgentConfig config, ICredentialProvider credentials, ILogger logger)
{
this.config = config;
this.credentials = credentials;
this.logger = logger;
pins = ServerPinSet.Load(config, logger);
}
public ICredentialProvider Credentials => credentials;
// Caller owns and disposes the client (it disposes the handler chain).
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
{
var sockets = new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (_, cert, _, errors)
=> pins.Validate(cert, errors, channel),
},
};
switch (channel)
{
case AgentHttpChannel.Command:
// Long-poll: retire pooled TLS connections faster than the
// 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;
}
var auth = new AgentAuthHandler(credentials, logger,
sharedKeyOnly: channel == AgentHttpChannel.Enroll)
{
InnerHandler = sockets,
};
return new HttpClient(auth, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = timeout ?? DefaultTimeout(channel),
};
}
private static TimeSpan DefaultTimeout(AgentHttpChannel channel) => channel switch
{
// Claim is held open server-side (~25s); clear that plus slack.
AgentHttpChannel.Command => TimeSpan.FromSeconds(60),
AgentHttpChannel.Download => TimeSpan.FromMinutes(10),
_ => TimeSpan.FromSeconds(30),
};
}
// Adds exactly one auth header per request, read at send time so a credential
// issued or revoked mid-run takes effect on the next request. Watches 401/403
// replies for the auth error codes and reports them to the provider.
public sealed class AgentAuthHandler : DelegatingHandler
{
public const string CredentialHeader = "X-Endpoint-Credential";
public const string SharedKeyHeader = "X-API-Key";
private readonly ICredentialProvider credentials;
private readonly ILogger logger;
private readonly bool sharedKeyOnly;
public AgentAuthHandler(ICredentialProvider credentials, ILogger logger, bool sharedKeyOnly = false)
{
this.credentials = credentials;
this.logger = logger;
this.sharedKeyOnly = sharedKeyOnly;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Remove(CredentialHeader);
request.Headers.Remove(SharedKeyHeader);
var credential = sharedKeyOnly ? null : credentials.Current?.Credential;
if (credential is not null)
request.Headers.TryAddWithoutValidation(CredentialHeader, credential);
else if (credentials.SharedKey is string sharedKey)
request.Headers.TryAddWithoutValidation(SharedKeyHeader, sharedKey);
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
try
{
// Buffered so the caller can still read the body.
await response.Content.LoadIntoBufferAsync();
var body = await response.Content.ReadAsStringAsync(cancellationToken);
var code = AgentServerError.TryParse(body)?.Code;
if (!string.IsNullOrEmpty(code))
credentials.ReportRejected(code, credential);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogDebug(error, "Could not read an auth failure body");
}
}
return response;
}
}
// The server's error envelope: {"status":"error","data":{"error":{"code","message"}}}.
public sealed record AgentServerError(string Code, string Message)
{
public static AgentServerError? TryParse(string? body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
return null;
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
root = data;
if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object)
return null;
var code = error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String
? c.GetString() ?? "" : "";
var message = error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String
? m.GetString() ?? "" : "";
return new AgentServerError(code, message);
}
catch (JsonException)
{
return null;
}
}
}
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo,
// merged from HKLM\SOFTWARE\GE\ShopDB ServerPins (both views) and appsettings
// 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.
public sealed class ServerPinSet
{
public const string RegistryValue = "ServerPins";
private readonly HashSet<string> spkiPins;
private readonly string? legacyThumbprint;
private readonly ILogger logger;
private ServerPinSet(HashSet<string> spkiPins, string? legacyThumbprint, ILogger logger)
{
this.spkiPins = spkiPins;
this.legacyThumbprint = legacyThumbprint;
this.logger = logger;
}
public bool IsPinned => 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();
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);
}
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
{
if (certificate is null)
{
logger.LogWarning("Server provided no certificate ({Channel})", channel);
return false;
}
if (!IsPinned)
{
if (errors == SslPolicyErrors.None)
return true;
logger.LogWarning(
"Server certificate rejected on {Channel} channel: {Errors} ({Subject})",
channel, errors, certificate.Subject);
return false;
}
var owned = certificate as X509Certificate2;
var leaf = owned ?? new X509Certificate2(certificate);
try
{
var spki = SpkiSha256(leaf);
if (spkiPins.Contains(spki))
return true;
if (legacyThumbprint is not null
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
return true;
logger.LogError(
"Server certificate does not match any pin on {Channel} channel: spki {Spki} ({Subject})",
channel, spki, leaf.Subject);
return false;
}
finally
{
if (owned is null)
leaf.Dispose();
}
}
public static string SpkiSha256(X509Certificate2 certificate)
=> Convert.ToHexString(SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo()))
.ToLowerInvariant();
private static void AddPin(HashSet<string> pins, string? raw, string source, ILogger logger)
{
var pin = (raw ?? "").Replace(":", "").Replace(" ", "").Trim().ToLowerInvariant();
if (pin.Length == 0)
return;
if (pin.Length != 64 || !pin.All(Uri.IsHexDigit))
{
logger.LogWarning("Ignoring malformed server pin from {Source}: not 64 hex chars", source);
return;
}
pins.Add(pin);
}
private static IEnumerable<string> ReadRegistryPins(ILogger logger)
{
var found = new List<string>();
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
try
{
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
switch (key?.GetValue(RegistryValue))
{
case string[] many:
found.AddRange(many);
break;
case string one:
found.AddRange(one.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries));
break;
}
}
catch (Exception error)
{
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
}
}
return found;
}
}

View File

@@ -0,0 +1,180 @@
using System.IO.Pipes;
using System.Security.Principal;
using System.Text;
using System.Text.Json;
namespace ShopDbEndpointAgent.Core;
// Wire constants for the local agent pipe. One UTF-8 JSON object per line:
// request, response, close.
public static class AgentPipeProtocol
{
public const string PipeName = "ShopDB.EndpointAgent.v1";
public const int MaxRequestBytes = 64 * 1024;
public const int MaxResponseBytes = 4 * 1024 * 1024;
public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(3);
public static readonly TimeSpan RequestReadTimeout = TimeSpan.FromSeconds(10);
}
// Ok/Error as the agent answered. Data is the whole response object (ping
// fields such as agentversion/enrolled/assetid live there). Data is null when
// the agent could not be reached or answered garbage; Error then says why.
public sealed record PipeResponse(bool Ok, string? Error, JsonElement? Data);
public sealed class AgentPipeException : Exception
{
public AgentPipeException(string message) : base(message)
{
}
public AgentPipeException(string message, Exception inner) : base(message, inner)
{
}
}
// Client for the SYSTEM agent's local pipe. Used by the tray (which holds no
// secret) and by shopdbagent.exe --once/--report when not running as SYSTEM.
// Only GetAlertsAsync throws; the others report failure through PipeResponse.
public sealed class AgentPipeClient
{
// Send Status waits on the user-context refresh plus the upload retries.
private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(3);
private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(45);
private static readonly JsonSerializerOptions AlertOptions = new()
{
Converters = { new TolerantFleetAlertListConverter() },
};
public Task<PipeResponse> PingAsync(CancellationToken cancellationToken)
=> SendSafeAsync(new { op = "ping" }, ShortOperationTimeout, cancellationToken);
public Task<PipeResponse> SendStatusAsync(CancellationToken cancellationToken)
=> SendSafeAsync(new { op = "sendstatus" }, LongOperationTimeout, cancellationToken);
public Task<PipeResponse> SendReportAsync(string spoolPath, CancellationToken cancellationToken)
=> SendSafeAsync(new { op = "sendreport", spoolpath = spoolPath }, LongOperationTimeout,
cancellationToken);
public async Task<IReadOnlyList<FleetAlert>> GetAlertsAsync(CancellationToken cancellationToken)
{
var response = await SendAsync(new { op = "alerts" }, ShortOperationTimeout, cancellationToken);
if (!response.Ok)
throw new AgentPipeException(response.Error ?? "agent refused the alerts request");
if (response.Data is not JsonElement data
|| !data.TryGetProperty("alerts", out var alerts)
|| alerts.ValueKind != JsonValueKind.Array)
throw new AgentPipeException("agent response has no alerts array");
try
{
return alerts.Deserialize<List<FleetAlert>>(AlertOptions) ?? new List<FleetAlert>();
}
catch (JsonException error)
{
throw new AgentPipeException("agent alerts could not be parsed", error);
}
}
public Task<PipeResponse> AckAlertAsync(int notificationId, CancellationToken cancellationToken)
=> SendSafeAsync(new { op = "ackalert", notificationid = notificationId },
ShortOperationTimeout, cancellationToken);
private static async Task<PipeResponse> SendSafeAsync(
object request, TimeSpan timeout, CancellationToken cancellationToken)
{
try
{
return await SendAsync(request, timeout, cancellationToken);
}
catch (AgentPipeException error)
{
return new PipeResponse(false, error.Message, null);
}
}
// Throws AgentPipeException on any transport or framing failure, and
// OperationCanceledException only when the caller cancelled.
private static async Task<PipeResponse> SendAsync(
object request, TimeSpan timeout, CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
var token = timeoutSource.Token;
try
{
// Identification: the agent may learn who we are, never act as us.
await using var pipe = new NamedPipeClientStream(".", AgentPipeProtocol.PipeName,
PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification);
try
{
await pipe.ConnectAsync((int)AgentPipeProtocol.ConnectTimeout.TotalMilliseconds, token);
}
catch (TimeoutException error)
{
throw new AgentPipeException("agent not reachable (is the ShopDB Endpoint Agent service running?)", error);
}
var line = JsonSerializer.SerializeToUtf8Bytes(request);
await pipe.WriteAsync(line, token);
await pipe.WriteAsync(new[] { (byte)'\n' }, token);
await pipe.FlushAsync(token);
var body = await ReadLineAsync(pipe, AgentPipeProtocol.MaxResponseBytes, token)
?? throw new AgentPipeException("agent closed the pipe without answering");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
throw new AgentPipeException("agent response is not a JSON object");
var ok = root.TryGetProperty("ok", out var okValue) && okValue.ValueKind == JsonValueKind.True;
var errorText = root.TryGetProperty("error", out var errorValue)
&& errorValue.ValueKind == JsonValueKind.String
? errorValue.GetString() : null;
return new PipeResponse(ok, ok ? null : errorText ?? "agent reported failure", root.Clone());
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException error)
{
throw new AgentPipeException("agent did not answer in time", error);
}
catch (AgentPipeException)
{
throw;
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException
or InvalidDataException)
{
throw new AgentPipeException("agent pipe failed: " + error.Message, error);
}
}
// One newline-terminated UTF-8 line, or null on EOF before any byte.
// Throws InvalidDataException past maxBytes. Shared with the server.
internal static async Task<string?> ReadLineAsync(Stream stream, int maxBytes, CancellationToken token)
{
var buffer = new MemoryStream();
var chunk = new byte[4096];
while (true)
{
var read = await stream.ReadAsync(chunk, token);
if (read == 0)
return buffer.Length == 0 ? null : Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
var newline = Array.IndexOf(chunk, (byte)'\n', 0, read);
var take = newline >= 0 ? newline : read;
if (buffer.Length + take > maxBytes)
throw new InvalidDataException($"pipe message exceeds {maxBytes} bytes");
buffer.Write(chunk, 0, take);
if (newline >= 0)
return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
}
}
}

View File

@@ -0,0 +1,562 @@
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// \\.\pipe\ShopDB.EndpointAgent.v1: the tray's only way to reach the server.
// The tray holds no secret; it asks the SYSTEM agent to send status, upload a
// report, list alerts or acknowledge one, and the agent does it with its own
// credential. Protocol in AgentPipeProtocol / AgentPipeClient.
//
// ACL: SYSTEM and Administrators full control, INTERACTIVE read/write (no
// create-instance, so a user cannot add instances to our pipe). The first
// instance is created with FirstPipeInstance so a process that squatted the
// name before the service started is refused rather than joined.
public sealed class AgentPipeServer : BackgroundService
{
public const int MaxInstances = 8;
private const int MaxScreenshotBytes = 25 * 1024 * 1024;
private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(2);
private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(40);
private static readonly JsonSerializerOptions PayloadOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly AgentConfig config;
private readonly TelemetryWorker telemetry;
private readonly CollectorClient collector;
private readonly FleetAlertClient alerts;
private readonly ICredentialProvider credentials;
private readonly string agentVersion;
private readonly ILogger<AgentPipeServer> logger;
private readonly SemaphoreSlim statusGate = new(1, 1);
private readonly SemaphoreSlim reportGate = new(1, 1);
public AgentPipeServer(AgentConfig config, TelemetryWorker telemetry, CollectorClient collector,
FleetAlertClient alerts, ICredentialProvider credentials, string agentVersion,
ILogger<AgentPipeServer> logger)
{
this.config = config;
this.telemetry = telemetry;
this.collector = collector;
this.alerts = alerts;
this.credentials = credentials;
this.agentVersion = agentVersion;
this.logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var slots = new SemaphoreSlim(MaxInstances, MaxInstances);
bool first = true;
while (!stoppingToken.IsCancellationRequested)
{
try
{
await slots.WaitAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
NamedPipeServerStream pipe;
try
{
pipe = CreateInstance(first);
}
catch (Exception error)
{
slots.Release();
logger.LogError(error,
first
? "Could not create the agent pipe; another process may own the name. Retrying in 30s."
: "Could not create an agent pipe instance. Retrying in 30s.");
if (!await SafeDelay(TimeSpan.FromSeconds(30), stoppingToken))
break;
continue;
}
if (first)
{
first = false;
logger.LogInformation("Agent pipe listening on {Pipe}", AgentPipeProtocol.PipeName);
RestrictAppSettingsAcl();
}
try
{
await pipe.WaitForConnectionAsync(stoppingToken);
}
catch (Exception error)
{
pipe.Dispose();
slots.Release();
if (stoppingToken.IsCancellationRequested)
break;
logger.LogWarning(error, "Agent pipe connection failed");
if (!await SafeDelay(TimeSpan.FromSeconds(1), stoppingToken))
break;
continue;
}
_ = Task.Run(async () =>
{
try
{
await HandleConnectionAsync(pipe, stoppingToken);
}
catch (Exception error)
{
logger.LogDebug(error, "Agent pipe request ended with an error");
}
finally
{
pipe.Dispose();
slots.Release();
}
}, CancellationToken.None);
}
}
private static NamedPipeServerStream CreateInstance(bool first)
{
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
PipeAccessRights.FullControl, AccessControlType.Allow));
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
PipeAccessRights.FullControl, AccessControlType.Allow));
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.InteractiveSid, null),
PipeAccessRights.ReadWrite, AccessControlType.Allow));
var options = PipeOptions.Asynchronous;
if (first)
options |= PipeOptions.FirstPipeInstance;
return NamedPipeServerStreamAcl.Create(AgentPipeProtocol.PipeName, PipeDirection.InOut,
MaxInstances, PipeTransmissionMode.Byte, options, 4096, 4096, security);
}
private async Task HandleConnectionAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
{
string? line;
using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken))
{
readTimeout.CancelAfter(AgentPipeProtocol.RequestReadTimeout);
try
{
line = await AgentPipeClient.ReadLineAsync(pipe, AgentPipeProtocol.MaxRequestBytes,
readTimeout.Token);
}
catch (InvalidDataException)
{
await WriteAsync(pipe, Failure("request too large"), stoppingToken);
return;
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
logger.LogDebug("Agent pipe client sent no request within {Timeout}",
AgentPipeProtocol.RequestReadTimeout);
return;
}
}
if (line is null)
return;
// Impersonation needs a completed read first, which we just did.
var (callerName, callerSid) = IdentifyCaller(pipe);
string op;
JsonElement request;
try
{
using var doc = JsonDocument.Parse(line);
request = doc.RootElement.Clone();
op = request.ValueKind == JsonValueKind.Object
&& request.TryGetProperty("op", out var opValue) && opValue.ValueKind == JsonValueKind.String
? opValue.GetString() ?? ""
: "";
}
catch (JsonException)
{
await WriteAsync(pipe, Failure("request is not valid JSON"), stoppingToken);
return;
}
object response;
try
{
response = op switch
{
"ping" => Ping(),
"sendstatus" => await SendStatusAsync(callerName, stoppingToken),
"sendreport" => await SendReportAsync(request, callerName, callerSid, stoppingToken),
"alerts" => await ListAlertsAsync(stoppingToken),
"ackalert" => await AckAlertAsync(request, callerName, stoppingToken),
_ => Failure("unknown op"),
};
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
{
response = Failure("the agent timed out talking to ShopDB");
}
await WriteAsync(pipe, response, stoppingToken);
}
private (string? Name, SecurityIdentifier? Sid) IdentifyCaller(NamedPipeServerStream pipe)
{
string? name = null;
SecurityIdentifier? sid = null;
try
{
name = pipe.GetImpersonationUserName();
}
catch (Exception error)
{
logger.LogDebug(error, "GetImpersonationUserName failed");
}
try
{
pipe.RunAsClient(() =>
{
using var identity = WindowsIdentity.GetCurrent(TokenAccessLevels.Query);
sid = identity.User;
});
}
catch (Exception error)
{
logger.LogDebug(error, "Could not read the pipe caller's SID");
}
return (name, sid);
}
private object Ping()
{
var current = credentials.Current;
return new
{
ok = true,
agentversion = agentVersion,
enrolled = current is not null,
assetid = current?.AssetId,
};
}
private async Task<object> SendStatusAsync(string? caller, CancellationToken stoppingToken)
{
if (!await statusGate.WaitAsync(0, stoppingToken))
return Failure("a status send is already running");
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(LongOperationTimeout);
logger.LogInformation("Status send requested over the pipe by {Caller}", caller);
var result = await telemetry.RunOnceAsync(config.AdoptOnFirstRun, timeout.Token);
logger.LogInformation("Pipe status send complete: action={Action}", result.Action);
return Success();
}
catch (CollectorException error)
{
logger.LogWarning("Pipe status send failed: HTTP {Status}", error.StatusCode);
return Failure(ServerFailure(error));
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
&& !stoppingToken.IsCancellationRequested)
{
logger.LogWarning(error, "Pipe status send failed");
return Failure("could not reach ShopDB");
}
finally
{
statusGate.Release();
}
}
// Does what --report does, but only with files inside the CALLER's own
// profile: the pipe is open to every interactive user and this process is
// SYSTEM, so an unchecked path would upload any file SYSTEM can read. The
// sidecar is not deleted here (the tray removes it); deleting in a user-
// writable tree as SYSTEM is a redirection target.
private async Task<object> SendReportAsync(JsonElement request, string? caller,
SecurityIdentifier? callerSid, CancellationToken stoppingToken)
{
if (callerSid is null)
return Failure("could not identify the calling user");
var spoolPath = request.TryGetProperty("spoolpath", out var pathValue)
&& pathValue.ValueKind == JsonValueKind.String
? pathValue.GetString()
: null;
if (string.IsNullOrWhiteSpace(spoolPath))
return Failure("spoolpath is required");
var profile = ProfilePathFor(callerSid);
if (profile is null)
return Failure("no local profile for the calling user");
if (!await reportGate.WaitAsync(0, stoppingToken))
return Failure("a report upload is already running");
try
{
OperatorReportPayload? payload;
try
{
using var sidecar = OpenCallerFile(spoolPath, profile, AgentPipeProtocol.MaxRequestBytes);
payload = await JsonSerializer.DeserializeAsync<OperatorReportPayload>(
sidecar, PayloadOptions, stoppingToken);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException
or JsonException or ArgumentException
or NotSupportedException)
{
logger.LogWarning("Refused report sidecar from {Caller}: {Reason}", caller, error.Message);
return Failure("report file refused: " + error.Message);
}
if (payload is null)
return Failure("report file is empty");
FileStream? screenshot = null;
try
{
if (!string.IsNullOrWhiteSpace(payload.ScreenshotPath))
{
if (!payload.ScreenshotPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
return Failure("screenshot must be a .png file");
try
{
screenshot = OpenCallerFile(payload.ScreenshotPath, profile, MaxScreenshotBytes);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
logger.LogWarning("Refused report screenshot from {Caller}: {Reason}",
caller, error.Message);
return Failure("screenshot refused: " + error.Message);
}
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(LongOperationTimeout);
var uploaded = await collector.UploadOperatorReportAsync(
payload.Reason, payload.Details, screenshot,
payload.ScreenshotPath is null ? null : Path.GetFileName(payload.ScreenshotPath),
Environment.MachineName, timeout.Token);
logger.LogInformation("Operator report from {Caller} uploaded: id={ReportId}",
caller, uploaded?.OperatorReportId);
return Success();
}
catch (CollectorException error)
{
logger.LogWarning("Operator report upload failed: HTTP {Status}", error.StatusCode);
return Failure(ServerFailure(error));
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
&& !stoppingToken.IsCancellationRequested)
{
logger.LogWarning(error, "Operator report upload failed");
return Failure("could not reach ShopDB");
}
finally
{
screenshot?.Dispose();
}
}
finally
{
reportGate.Release();
}
}
private async Task<object> ListAlertsAsync(CancellationToken stoppingToken)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(ShortOperationTimeout);
var list = await alerts.FetchAlertsAsync(Environment.MachineName, timeout.Token);
return new { ok = true, alerts = list.ToArray() };
}
catch (CollectorException error)
{
logger.LogDebug("Alert fetch for the tray failed: HTTP {Status}", error.StatusCode);
return Failure(ServerFailure(error));
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException or JsonException
&& !stoppingToken.IsCancellationRequested)
{
logger.LogDebug(error, "Alert fetch for the tray failed");
return Failure("could not reach ShopDB");
}
}
private async Task<object> AckAlertAsync(JsonElement request, string? caller, CancellationToken stoppingToken)
{
if (!request.TryGetProperty("notificationid", out var idValue)
|| idValue.ValueKind != JsonValueKind.Number
|| !idValue.TryGetInt32(out var notificationId)
|| notificationId <= 0)
return Failure("notificationid must be a positive integer");
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeout.CancelAfter(ShortOperationTimeout);
// 0 = "the caller": the server resolves it from credential or hostname.
var assetId = credentials.Current?.AssetId ?? 0;
await alerts.AcknowledgeAlertAsync(notificationId, assetId, Environment.MachineName, timeout.Token);
logger.LogInformation("Alert {NotificationId} acknowledged by {Caller}", notificationId, caller);
return Success();
}
catch (CollectorException error)
{
logger.LogWarning("Alert {NotificationId} acknowledge failed: HTTP {Status}",
notificationId, error.StatusCode);
return Failure(ServerFailure(error));
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
&& !stoppingToken.IsCancellationRequested)
{
logger.LogWarning(error, "Alert {NotificationId} acknowledge failed", notificationId);
return Failure("could not reach ShopDB");
}
}
// Profile root for a SID from ProfileList. Null when absent.
private static string? ProfilePathFor(SecurityIdentifier sid)
{
try
{
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
using var key = machine.OpenSubKey(
@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + sid.Value);
if (key?.GetValue("ProfileImagePath") is not string raw || string.IsNullOrWhiteSpace(raw))
return null;
return Path.GetFullPath(Environment.ExpandEnvironmentVariables(raw)).TrimEnd('\\');
}
catch
{
return null;
}
}
// Open a caller-named file only if it sits inside the caller's profile, no
// directory from the profile down is a reparse point, the file itself is
// not one, and the opened handle's final path is still inside the profile.
private static FileStream OpenCallerFile(string path, string profile, int maxBytes)
{
if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal))
throw new UnauthorizedAccessException("path must be a local absolute path");
var full = Path.GetFullPath(path);
var root = profile + @"\";
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("path is outside the caller's profile");
var directory = Path.GetDirectoryName(full);
while (directory is not null && directory.Length >= profile.Length)
{
if (ProtectedFileSystem.IsReparsePoint(directory))
throw new UnauthorizedAccessException("path crosses a reparse point");
directory = Path.GetDirectoryName(directory);
}
var stream = ProtectedFileSystem.OpenReadNoFollow(full);
try
{
var final = ProtectedFileSystem.FinalPath(stream.SafeFileHandle);
if (final is null || !final.StartsWith(root, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("path resolves outside the caller's profile");
if (stream.Length > maxBytes)
throw new IOException($"file is larger than {maxBytes} bytes");
return stream;
}
catch
{
stream.Dispose();
throw;
}
}
// Only SYSTEM (the service) and Administrators read appsettings.json now:
// no user-context process needs it once the tray talks through this pipe.
private void RestrictAppSettingsAcl()
{
try
{
using var identity = WindowsIdentity.GetCurrent();
if (!identity.IsSystem)
return;
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (ProtectedFileSystem.TryGetAttributes(path) is not FileAttributes attributes
|| attributes.HasFlag(FileAttributes.ReparsePoint))
return;
var security = new FileSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
FileSystemRights.FullControl, AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
FileSystemRights.FullControl, AccessControlType.Allow));
new FileInfo(path).SetAccessControl(security);
logger.LogInformation("appsettings.json restricted to SYSTEM and Administrators.");
}
catch (Exception error)
{
logger.LogWarning(error, "Could not restrict the appsettings.json ACL");
}
}
private static string ServerFailure(CollectorException error)
=> string.IsNullOrEmpty(error.ServerCode)
? $"ShopDB returned HTTP {error.StatusCode}"
: $"ShopDB returned HTTP {error.StatusCode} {error.ServerCode}";
private static object Success() => new { ok = true };
private static object Failure(string error) => new { ok = false, error };
private static async Task WriteAsync(Stream pipe, object response, CancellationToken token)
{
try
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
await pipe.WriteAsync(bytes, token);
await pipe.WriteAsync(new[] { (byte)'\n' }, token);
await pipe.FlushAsync(token);
}
catch (IOException)
{
// Client went away.
}
}
private static async Task<bool> SafeDelay(TimeSpan delay, CancellationToken token)
{
try
{
await Task.Delay(delay, token);
return true;
}
catch (OperationCanceledException)
{
return false;
}
}
}

View File

@@ -1,86 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
/// <summary>
/// Background service that periodically checks client certificate expiry.
/// If certificate expires within 30 days, triggers renewal and service restart.
///
/// This is critical for long-running agents that don't restart frequently.
/// Without this, an agent running > 30 days will have an expired cert with no
/// automatic renewal, causing authentication failures.
/// </summary>
public class CertificateExpiryMonitor : BackgroundService
{
private readonly ILogger<CertificateExpiryMonitor> logger;
private readonly ILogger<CertificateRenewal> renewalLogger;
private readonly AgentConfig config;
private readonly IHostApplicationLifetime lifetime;
private const int CHECK_INTERVAL_HOURS = 24;
private const int RENEWAL_THRESHOLD_DAYS = 30;
public CertificateExpiryMonitor(
ILogger<CertificateExpiryMonitor> logger,
ILogger<CertificateRenewal> renewalLogger,
AgentConfig config,
IHostApplicationLifetime lifetime)
{
this.logger = logger;
this.renewalLogger = renewalLogger;
this.config = config;
this.lifetime = lifetime;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Wait 1 hour after service start before first check (let agent stabilize)
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CheckCertificateExpiry();
}
catch (Exception ex)
{
logger.LogError(ex, "Certificate expiry check failed");
}
// Check every 24 hours
await Task.Delay(TimeSpan.FromHours(CHECK_INTERVAL_HOURS), stoppingToken);
}
}
private async Task CheckCertificateExpiry()
{
if (string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
// No client cert configured, nothing to check
return;
}
logger.LogDebug("Performing periodic certificate expiry check");
// Use same renewal logic as startup check
var renewal = new CertificateRenewal(renewalLogger, config);
bool didRenew = await renewal.CheckAndRenewIfNeeded();
if (didRenew)
{
logger.LogWarning(
"Certificate was renewed during periodic check. Agent has been running " +
"for an extended period without restart. Stopping service to apply new " +
"certificate. Service manager will restart the agent.");
// Stop the application gracefully
// Windows Service Manager will restart it (if configured as Automatic)
lifetime.StopApplication();
}
}
}

View File

@@ -1,327 +0,0 @@
using System;
using System.IO;
using System.Net.Http;
using System.Security.AccessControl;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
/// <summary>
/// Automatic client certificate renewal when expiring within 30 days.
/// Agent checks cert expiry on startup, requests renewal from server, installs
/// new cert, updates config, and schedules service restart.
/// </summary>
public class CertificateRenewal
{
private readonly ILogger<CertificateRenewal> logger;
private readonly AgentConfig config;
private const int RENEWAL_THRESHOLD_DAYS = 30;
private const string RENEWAL_ENDPOINT = "/api/endpoints/certificate/renew";
public CertificateRenewal(ILogger<CertificateRenewal> logger, AgentConfig config)
{
this.logger = logger;
this.config = config;
}
/// <summary>
/// Check if client certificate needs renewal (expires in < 30 days).
/// Called on agent startup before first telemetry report.
/// </summary>
public async Task<bool> CheckAndRenewIfNeeded()
{
if (string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
// No cert configured, skip renewal check
return false;
}
// Find current certificate
var cert = FindClientCertificate(config.ClientCertThumbprint);
if (cert == null)
{
logger.LogWarning(
"Certificate {Thumbprint} not found in LocalMachine\\My store. " +
"Cannot check expiry or renew.",
config.ClientCertThumbprint);
return false;
}
// Check expiry
var daysRemaining = (cert.NotAfter - DateTime.Now).TotalDays;
logger.LogInformation(
"Client certificate expires {ExpiryDate} ({DaysRemaining:F1} days remaining)",
cert.NotAfter, daysRemaining);
if (daysRemaining > RENEWAL_THRESHOLD_DAYS)
{
// Certificate still valid for > 30 days, no renewal needed
return false;
}
// Certificate expiring soon or already expired, request renewal
logger.LogWarning(
"Certificate expires in {DaysRemaining:F1} days (threshold {Threshold} days). " +
"Requesting renewal from server...",
daysRemaining, RENEWAL_THRESHOLD_DAYS);
try
{
await RenewCertificate(cert);
return true; // Renewal succeeded, service should restart
}
catch (Exception ex)
{
logger.LogError(ex,
"Certificate renewal failed. Agent will continue with existing cert " +
"until next startup or manual intervention.");
return false;
}
}
private X509Certificate2? FindClientCertificate(string thumbprint)
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
// Thumbprint lookup is case-insensitive, no separators
var normalizedThumbprint = thumbprint.Replace(":", "").Replace(" ", "");
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
normalizedThumbprint,
validOnly: false); // Include expired certs for renewal
return certs.Count > 0 ? certs[0] : null;
}
private async Task RenewCertificate(X509Certificate2 oldCert)
{
// Step 1: Request new certificate from server
var response = await RequestCertificateFromServer(oldCert);
if (response == null)
{
throw new InvalidOperationException("Server returned null renewal response");
}
// Step 2: Import certificate to LocalMachine\My store
var newCert = ImportPfxCertificate(response.Pfx, response.Password);
// Step 3: Update appsettings.json with new thumbprint
UpdateConfigFile(response.Thumbprint);
// Step 4: Grant Users group access to private key (for tray app)
GrantPrivateKeyAccess(newCert);
logger.LogInformation(
"Certificate renewal complete. New cert thumbprint: {Thumbprint}, " +
"expires: {ExpiryDate}. Service restart required.",
response.Thumbprint, response.ExpiresAt);
}
private async Task<RenewalResponse?> RequestCertificateFromServer(X509Certificate2 currentCert)
{
var handler = new HttpClientHandler();
// Attach current (expiring) certificate to authenticate renewal request
handler.ClientCertificates.Add(currentCert);
// Server certificate validation: MUST validate, never accept blindly
handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogError("Server provided no certificate during renewal request");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var actualThumbprint = cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch during renewal. " +
"Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: validate against Windows trusted roots
// This prevents MITM attacks when pinning is not explicitly configured
if (errors == System.Net.Security.SslPolicyErrors.None)
{
// Certificate is valid and trusted by Windows
return true;
}
logger.LogError(
"Server certificate validation failed during renewal: {Errors}. " +
"Configure ServerCertThumbprint for certificate pinning or ensure " +
"server certificate is trusted by Windows certificate store.",
errors);
return false;
};
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
// Add X-API-Key header for API key fallback (when cert is expired and nginx rejects it)
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
var url = config.BaseUrl.TrimEnd('/') + RENEWAL_ENDPOINT;
logger.LogInformation("Requesting certificate renewal from {Url}", url);
// Send hostname in request body for API key fallback authentication
var requestBody = JsonSerializer.Serialize(new { hostname = Environment.MachineName });
var httpResponse = await http.PostAsync(url,
new StringContent(requestBody, Encoding.UTF8, "application/json"));
if (!httpResponse.IsSuccessStatusCode)
{
var errorBody = await httpResponse.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Certificate renewal request failed: {httpResponse.StatusCode} - {errorBody}");
}
var json = await httpResponse.Content.ReadAsStringAsync();
var envelope = JsonSerializer.Deserialize<RenewalResponseEnvelope>(json);
if (envelope?.Data == null)
{
throw new InvalidOperationException("Server returned invalid renewal response");
}
return envelope.Data;
}
private X509Certificate2 ImportPfxCertificate(string pfxBase64, string password)
{
var pfxBytes = Convert.FromBase64String(pfxBase64);
// Import to LocalMachine\My store
var cert = new X509Certificate2(pfxBytes, password,
X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet);
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadWrite);
store.Add(cert);
logger.LogInformation(
"Imported new certificate to LocalMachine\\My: {Subject}, thumbprint: {Thumbprint}",
cert.Subject, cert.Thumbprint);
return cert;
}
private void UpdateConfigFile(string newThumbprint)
{
// appsettings.json location: same directory as executable
var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(configPath))
{
throw new FileNotFoundException($"Configuration file not found: {configPath}");
}
// Read, parse, update, write back
var json = File.ReadAllText(configPath);
var doc = JsonDocument.Parse(json);
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.Name.Equals("clientcertthumbprint", StringComparison.OrdinalIgnoreCase))
{
writer.WriteString(prop.Name, newThumbprint);
}
else
{
prop.WriteTo(writer);
}
}
writer.WriteEndObject();
writer.Flush();
var updatedJson = Encoding.UTF8.GetString(stream.ToArray());
File.WriteAllText(configPath, updatedJson);
logger.LogInformation("Updated {ConfigPath} with new certificate thumbprint", configPath);
}
private void GrantPrivateKeyAccess(X509Certificate2 cert)
{
// Grant BUILTIN\Users read access to private key so tray app can use it
try
{
var rsaKey = cert.GetRSAPrivateKey();
if (rsaKey == null)
{
logger.LogWarning("Certificate has no RSA private key, cannot set permissions");
return;
}
// CNG key: use key name to find file
if (rsaKey is System.Security.Cryptography.RSACng cngKey)
{
var keyPath = cngKey.Key.UniqueName;
var searchPaths = new[]
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
@"Microsoft\Crypto\SystemKeys"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
@"Microsoft\Crypto\Keys"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
@"Microsoft\Crypto\RSA\MachineKeys")
};
foreach (var searchPath in searchPaths)
{
var keyFile = Path.Combine(searchPath, keyPath);
if (File.Exists(keyFile))
{
var fileInfo = new FileInfo(keyFile);
var acl = fileInfo.GetAccessControl();
var rule = new FileSystemAccessRule(
"BUILTIN\\Users",
FileSystemRights.Read | FileSystemRights.ReadAndExecute,
AccessControlType.Allow);
acl.AddAccessRule(rule);
fileInfo.SetAccessControl(acl);
logger.LogInformation("Granted Users group access to private key at {KeyFile}", keyFile);
return;
}
}
logger.LogWarning("Private key file not found in any search path");
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not set private key permissions (tray app may not work)");
}
}
// JSON response schema
private class RenewalResponseEnvelope
{
public RenewalResponse? Data { get; set; }
}
private class RenewalResponse
{
public string Pfx { get; set; } = "";
public string Password { get; set; } = "";
public string Thumbprint { get; set; } = "";
public DateTime ExpiresAt { get; set; }
}
}

View File

@@ -1,13 +1,13 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Core;
// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the // Posts one EndpointReport to POST {baseurl}/api/endpoints/collect (per-PC
// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping // credential) or /api/collector/endpoints (shared key), auth added by
// AgentHttpFactory (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
// the ShopDB envelope { status, data, meta } to return the CollectorResponse. // the ShopDB envelope { status, data, meta } to return the CollectorResponse.
// //
// Resilience: transient failures (network errors, timeouts, 5xx, 429) are // Resilience: transient failures (network errors, timeouts, 5xx, 429) are
@@ -28,124 +28,15 @@ public sealed class CollectorClient : IDisposable
private readonly int maxAttempts; private readonly int maxAttempts;
private readonly Random jitter = new(); private readonly Random jitter = new();
public CollectorClient(AgentConfig config, ILogger<CollectorClient> logger, private readonly ICredentialProvider credentials;
public CollectorClient(AgentHttpFactory httpFactory, ILogger<CollectorClient> logger,
int maxAttempts = 4) int maxAttempts = 4)
{ {
this.logger = logger; this.logger = logger;
this.maxAttempts = Math.Max(1, maxAttempts); this.maxAttempts = Math.Max(1, maxAttempts);
credentials = httpFactory.Credentials;
// Create HttpClient with optional client certificate for mTLS http = httpFactory.CreateClient(AgentHttpChannel.Collector);
var handler = CreateHttpClientHandler(config);
http = new HttpClient(handler)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
// Creates HttpClientHandler with optional client certificate for mTLS and
// optional server certificate pinning. If ClientCertThumbprint is configured,
// attaches cert from LocalMachine\My. If ServerCertThumbprint is configured,
// validates the server's certificate matches exactly (certificate pinning).
private HttpClientHandler CreateHttpClientHandler(AgentConfig config)
{
var handler = new HttpClientHandler();
// Server certificate validation: pinning when configured, otherwise chain validation
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogWarning("Server provided no certificate");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var actualThumbprint = cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch. Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: normal Windows chain validation
if (errors == System.Net.Security.SslPolicyErrors.None)
return true;
logger.LogWarning(
"Server certificate rejected on collector channel: {Errors} ({Subject})",
errors, cert.Subject);
return false;
};
// Attach client certificate if configured
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
if (clientCert != null)
{
handler.ClientCertificates.Add(clientCert);
logger.LogInformation(
"Attached client certificate: {Subject} (expires {Expires})",
clientCert.Subject, clientCert.NotAfter);
}
else
{
logger.LogWarning(
"ClientCertThumbprint configured ({Thumbprint}) but certificate not found in LocalMachine\\My",
config.ClientCertThumbprint);
}
}
return handler;
}
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
// Returns null if not found. The certificate must have a private key.
private X509Certificate2? FindClientCertificate(string thumbprint)
{
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint.Trim(),
validOnly: false);
if (certs.Count == 0)
return null;
var cert = certs[0];
if (!cert.HasPrivateKey)
{
logger.LogWarning(
"Found certificate {Subject} but it has no private key",
cert.Subject);
return null;
}
return cert;
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to load client certificate with thumbprint {Thumbprint}",
thumbprint);
return null;
}
} }
public async Task<CollectorResponse> SendAsync( public async Task<CollectorResponse> SendAsync(
@@ -184,8 +75,12 @@ public sealed class CollectorClient : IDisposable
private async Task<CollectorResponse> SendOnceAsync( private async Task<CollectorResponse> SendOnceAsync(
EndpointReport report, CancellationToken token) EndpointReport report, CancellationToken token)
{ {
using var response = await http.PostAsJsonAsync( // Enrolled agents post to the credential route; the shared key keeps
"api/collector/endpoints", report, JsonOptions, token); // the core collector route so this build still reports to an older server.
var route = credentials.Current is not null
? "api/endpoints/collect"
: "api/collector/endpoints";
using var response = await http.PostAsJsonAsync(route, report, JsonOptions, token);
var body = await response.Content.ReadAsStringAsync(token); var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -199,7 +94,7 @@ public sealed class CollectorClient : IDisposable
// Fetch the server-resolved watch list (policy-attached processes/services) // Fetch the server-resolved watch list (policy-attached processes/services)
// for this hostname, GET /api/endpoints/monitors/resolved. Shares the same // for this hostname, GET /api/endpoints/monitors/resolved. Shares the same
// X-API-Key credential as telemetry. Best-effort: returns null on any // auth as telemetry. Best-effort: returns null on any
// failure so the caller falls back to the last-known/local list rather than // failure so the caller falls back to the last-known/local list rather than
// blanking monitoring on a transient blip. A single attempt, no retry - the // blanking monitoring on a transient blip. A single attempt, no retry - the
// next telemetry cycle retries naturally, and a stale-but-usable watch list // next telemetry cycle retries naturally, and a stale-but-usable watch list
@@ -242,49 +137,77 @@ public sealed class CollectorClient : IDisposable
// Uploads one operator issue report (free-text reason/details plus an // Uploads one operator issue report (free-text reason/details plus an
// optional PNG screenshot) to POST /api/endpoints/operatorreports as // optional PNG screenshot) to POST /api/endpoints/operatorreports as
// multipart/form-data, authorized by the same X-API-Key collector key as // multipart/form-data, authorized the same way as telemetry. The server
// telemetry. The server resolves this hostname to its asset. A single // resolves this hostname (or the credential) to its asset. A single
// attempt: the tray shows the operator the outcome and can retry by hand, // attempt: the tray shows the operator the outcome and can retry by hand,
// and a stuck retry loop holding a screenshot in memory is worse than a // and a stuck retry loop holding a screenshot in memory is worse than a
// clear failure. Returns the parsed upload response (new row id) or throws. // clear failure. Returns the parsed upload response (new row id) or throws.
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync( public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
string reason, string details, string? screenshotPath, string hostname, string reason, string details, string? screenshotPath, string hostname,
CancellationToken token) CancellationToken token)
{
FileStream? imageStream = null;
try
{
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath))
imageStream = File.OpenRead(screenshotPath);
return await UploadOperatorReportAsync(reason, details, imageStream,
screenshotPath is null ? null : Path.GetFileName(screenshotPath),
hostname, token);
}
finally
{
imageStream?.Dispose();
}
}
// Same upload from an already opened screenshot stream. The pipe path opens
// the file itself (no reparse follow, final path checked) and hands it here.
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
string reason, string details, Stream? screenshot, string? screenshotName,
string hostname, CancellationToken token)
{ {
using var form = new MultipartFormDataContent(); using var form = new MultipartFormDataContent();
form.Add(new StringContent(reason ?? string.Empty), "reason"); form.Add(new StringContent(reason ?? string.Empty), "reason");
form.Add(new StringContent(details ?? string.Empty), "details"); form.Add(new StringContent(details ?? string.Empty), "details");
form.Add(new StringContent(hostname ?? string.Empty), "hostname"); form.Add(new StringContent(hostname ?? string.Empty), "hostname");
StreamContent? imageContent = null; if (screenshot is not null)
FileStream? imageStream = null;
try
{ {
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) // Not disposed with the form: the caller owns the stream.
{ var imageContent = new StreamContent(new NonClosingStream(screenshot));
imageStream = File.OpenRead(screenshotPath); imageContent.Headers.ContentType =
imageContent = new StreamContent(imageStream); new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
imageContent.Headers.ContentType = form.Add(imageContent, "screenshot", screenshotName ?? "screenshot.png");
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
form.Add(imageContent, "screenshot",
Path.GetFileName(screenshotPath));
}
using var response = await http.PostAsync(
"api/endpoints/operatorreports", form, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
finally
{
imageContent?.Dispose();
imageStream?.Dispose();
} }
using var response = await http.PostAsync(
"api/endpoints/operatorreports", form, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
private sealed class NonClosingStream : Stream
{
private readonly Stream inner;
public NonClosingStream(Stream inner) => this.inner = inner;
public override bool CanRead => inner.CanRead;
public override bool CanSeek => inner.CanSeek;
public override bool CanWrite => false;
public override long Length => inner.Length;
public override long Position { get => inner.Position; set => inner.Position = value; }
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> inner.ReadAsync(buffer, cancellationToken);
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
} }
public void Dispose() => http.Dispose(); public void Dispose() => http.Dispose();
@@ -301,9 +224,15 @@ public sealed class CollectorException : Exception
StatusCode >= 400 && StatusCode < 500 StatusCode >= 400 && StatusCode < 500
&& StatusCode != (int)HttpStatusCode.TooManyRequests; && StatusCode != (int)HttpStatusCode.TooManyRequests;
public string Body { get; }
// Error code from the server's JSON envelope (e.g. CREDENTIAL_INVALID), or null.
public string? ServerCode => AgentServerError.TryParse(Body)?.Code;
public CollectorException(int statusCode, string body) public CollectorException(int statusCode, string body)
: base($"collector returned HTTP {statusCode}: {body}") : base($"collector returned HTTP {statusCode}: {body}")
{ {
StatusCode = statusCode; StatusCode = statusCode;
Body = body;
} }
} }

View File

@@ -1,6 +1,5 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -11,9 +10,8 @@ namespace ShopDbEndpointAgent.Core;
// GET /api/endpoints/commands/next, which the server holds open until a command // GET /api/endpoints/commands/next, which the server holds open until a command
// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the // is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the
// claimed command locally the agent posts the outcome to // claimed command locally the agent posts the outcome to
// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector // POST /api/endpoints/commands/{id}/result. Both use the same auth as telemetry
// key via X-API-Key, so the command channel rides the same kill switch as // (per-PC credential, else the shared collector key), added by AgentHttpFactory.
// telemetry and adds no second secret.
public sealed class CommandClient : IDisposable public sealed class CommandClient : IDisposable
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@@ -23,119 +21,13 @@ public sealed class CommandClient : IDisposable
}; };
private readonly HttpClient http; private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger<CommandClient> logger; private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> logger) public CommandClient(AgentHttpFactory httpFactory, ILogger<CommandClient> logger)
{ {
this.logger = logger; this.logger = logger;
// The command channel long-polls: the server holds each claim open for a // Pooling tuned for the long-poll lives in AgentHttpFactory (Command).
// hold window and then, between windows, the pooled TLS connection sits http = httpFactory.CreateClient(AgentHttpChannel.Command);
// idle. Gunicorn's keep-alive closes an idle socket after a couple of
// seconds, so a connection reused after that yields "unexpected EOF or 0
// bytes from the transport stream" on the next TLS handshake. Retire
// pooled connections faster than the server's keep-alive so a socket the
// server has already dropped is never handed back out; the lifetime cap
// is a hard backstop so no connection lingers indefinitely.
handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
SslOptions = CreateSslOptions(config),
};
http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
// Longer than the collector client: the claim request is held open
// server-side (~25s), so the read timeout must clear that plus slack.
Timeout = TimeSpan.FromSeconds(60),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
// Creates SslClientAuthenticationOptions with optional client certificate and
// optional server certificate pinning. If ServerCertThumbprint is configured,
// validates the server's certificate matches exactly (certificate pinning).
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
{
var options = new System.Net.Security.SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogWarning("Server provided no certificate");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
var actualThumbprint = x509Cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch on command channel. Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning on command channel: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: normal Windows chain validation
if (errors == System.Net.Security.SslPolicyErrors.None)
return true;
logger.LogWarning(
"Server certificate rejected on command channel: {Errors} ({Subject})",
errors, cert.Subject);
return false;
},
};
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
if (clientCert != null)
{
options.ClientCertificates = new X509CertificateCollection { clientCert };
logger.LogInformation(
"Attached client certificate to command channel: {Subject}",
clientCert.Subject);
}
}
return options;
}
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
private X509Certificate2? FindClientCertificate(string thumbprint)
{
try
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var certs = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint.Trim(),
validOnly: false);
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
}
catch (Exception ex)
{
logger.LogError(ex,
"Failed to load client certificate with thumbprint {Thumbprint}",
thumbprint);
return null;
}
} }
// Claim the next pending command for this hostname, or null when the server // Claim the next pending command for this hostname, or null when the server

View File

@@ -73,14 +73,18 @@ public sealed class CommandExecutor
private readonly ScheduledTaskPolicy scheduledTaskPolicy; private readonly ScheduledTaskPolicy scheduledTaskPolicy;
// Held for update_app: the agent joins InstallPath to BaseUrl and downloads // Held for update_app: the agent joins InstallPath to BaseUrl and downloads
// the pinned installer with its own collector credential (ApiKey), so the // the pinned installer with its own agent auth, so the installer body never
// installer body never rides the command wire. // rides the command wire.
private readonly AgentConfig config; private readonly AgentConfig config;
// Builds the script/package download clients (pins + auth).
private readonly AgentHttpFactory httpFactory;
public CommandExecutor(ILogger<CommandExecutor> logger, public CommandExecutor(ILogger<CommandExecutor> logger,
AgentConfig config, TimeSpan? startTimeout = null) AgentConfig config, AgentHttpFactory httpFactory, TimeSpan? startTimeout = null)
{ {
this.logger = logger; this.logger = logger;
this.httpFactory = httpFactory;
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
this.config = config ?? new AgentConfig(); this.config = config ?? new AgentConfig();
this.launchPaths = this.config.ResolveLaunchPaths(); this.launchPaths = this.config.ResolveLaunchPaths();
@@ -1168,12 +1172,12 @@ public sealed class CommandExecutor
}; };
} }
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey)) if (string.IsNullOrWhiteSpace(config.BaseUrl))
{ {
return new CommandResult return new CommandResult
{ {
Succeeded = false, Succeeded = false,
Message = "run_script cannot download: agent has no base URL or collector key", Message = "run_script cannot download: agent has no base URL",
Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null), Detail = ScriptDetail(name, script, scriptPath: null, exitCode: null),
}; };
} }
@@ -1205,11 +1209,9 @@ public sealed class CommandExecutor
} }
} }
using var handler = new HttpClientHandler(); using var client = httpFactory.CreateClient(
using var client = new HttpClient(handler); AgentHttpChannel.Download, TimeSpan.FromMinutes(5));
client.Timeout = TimeSpan.FromMinutes(5);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl);
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
using var response = await client.SendAsync( using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token); httpRequest, HttpCompletionOption.ResponseHeadersRead, token);
@@ -1883,12 +1885,12 @@ public sealed class CommandExecutor
}; };
} }
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(config.ApiKey)) if (string.IsNullOrWhiteSpace(config.BaseUrl))
{ {
return new CommandResult return new CommandResult
{ {
Succeeded = false, Succeeded = false,
Message = "update_app cannot download: agent has no base URL or collector key", Message = "update_app cannot download: agent has no base URL",
Detail = AppDetail(app, installerPath: null, exitCode: null), Detail = AppDetail(app, installerPath: null, exitCode: null),
}; };
} }
@@ -1906,11 +1908,9 @@ public sealed class CommandExecutor
ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName), ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName),
$"shopdb-app-{packageKey}-{Guid.NewGuid():N}{ext}"); $"shopdb-app-{packageKey}-{Guid.NewGuid():N}{ext}");
using var handler = new HttpClientHandler(); using var client = httpFactory.CreateClient(
using var client = new HttpClient(handler); AgentHttpChannel.Download, TimeSpan.FromMinutes(10));
client.Timeout = TimeSpan.FromMinutes(10);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl);
httpRequest.Headers.Add("X-API-Key", config.ApiKey);
using var response = await client.SendAsync( using var response = await client.SendAsync(
httpRequest, HttpCompletionOption.ResponseHeadersRead, token); httpRequest, HttpCompletionOption.ResponseHeadersRead, token);

View File

@@ -43,11 +43,13 @@ public sealed class CommandWorker : BackgroundService
private readonly CollectSignal? collectSignal; private readonly CollectSignal? collectSignal;
private readonly AgentConfig? config; private readonly AgentConfig? config;
private readonly string hostname; private readonly string hostname;
private readonly EnrollmentService? enrollment;
public CommandWorker(CommandClient client, CommandExecutor executor, public CommandWorker(CommandClient client, CommandExecutor executor,
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null, ILogger<CommandWorker> logger, CollectSignal? collectSignal = null,
AgentConfig? config = null) AgentConfig? config = null, EnrollmentService? enrollment = null)
{ {
this.enrollment = enrollment;
this.client = client; this.client = client;
this.executor = executor; this.executor = executor;
this.logger = logger; this.logger = logger;
@@ -72,6 +74,18 @@ public sealed class CommandWorker : BackgroundService
await SafeDelay(startupDelay, stoppingToken); await SafeDelay(startupDelay, stoppingToken);
} }
if (enrollment is not null)
{
try
{
await enrollment.WaitForFirstAttemptAsync(TimeSpan.FromSeconds(20), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
var pollStarted = System.Diagnostics.Stopwatch.StartNew(); var pollStarted = System.Diagnostics.Stopwatch.StartNew();

View File

@@ -0,0 +1,318 @@
using System.Management;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// Gets this PC its own endpoint credential (POST /api/endpoints/enroll) and
// keeps trying until it has one. Workers run on the shared key meanwhile, so an
// 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)
// 404 server predates enrollment: idle, retry every 6 h
// other back off 1, 5, 15 min (cap)
//
// Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on
// SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute.
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);
private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6);
private static readonly TimeSpan[] ErrorBackoff =
{
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15),
};
private static readonly TimeSpan MinWakeInterval = TimeSpan.FromMinutes(1);
private readonly AgentConfig config;
private readonly AgentHttpFactory httpFactory;
private readonly AgentCredentialProvider credentials;
private readonly CredentialStore store;
private readonly string agentVersion;
private readonly ILogger<EnrollmentService> logger;
private readonly SemaphoreSlim wake = new(0, 1);
private readonly TaskCompletionSource firstAttempt =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Random jitter = new();
public EnrollmentService(AgentConfig config, AgentHttpFactory httpFactory,
AgentCredentialProvider credentials, CredentialStore store, string agentVersion,
ILogger<EnrollmentService> logger)
{
this.config = config;
this.httpFactory = httpFactory;
this.credentials = credentials;
this.store = store;
this.agentVersion = agentVersion;
this.logger = logger;
credentials.EnrollmentNeeded += (_, _) => Wake();
}
// Workers await this (bounded) before first contact so a PC that can enroll
// right away does its first report on its own credential.
public async Task WaitForFirstAttemptAsync(TimeSpan limit, CancellationToken token)
{
try
{
await firstAttempt.Task.WaitAsync(limit, token);
}
catch (TimeoutException)
{
}
}
public void Wake()
{
try
{
if (wake.CurrentCount == 0)
wake.Release();
}
catch (SemaphoreFullException)
{
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
int failures = 0;
var lastAttempt = DateTime.MinValue;
using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll);
try
{
while (!stoppingToken.IsCancellationRequested)
{
if (credentials.Current is not null)
{
firstAttempt.TrySetResult();
await wake.WaitAsync(stoppingToken);
continue;
}
var sinceLast = DateTime.UtcNow - lastAttempt;
if (sinceLast < MinWakeInterval)
await Task.Delay(MinWakeInterval - sinceLast, stoppingToken);
lastAttempt = DateTime.UtcNow;
var outcome = await TryEnrollAsync(client, stoppingToken);
firstAttempt.TrySetResult();
TimeSpan delay;
switch (outcome)
{
case EnrollOutcome.Enrolled:
failures = 0;
continue;
case EnrollOutcome.Pending:
failures = 0;
delay = PendingRetry + TimeSpan.FromSeconds(
(jitter.NextDouble() * 2 - 1) * PendingJitter.TotalSeconds);
break;
case EnrollOutcome.Unsupported:
failures = 0;
delay = UnsupportedRetry;
break;
default:
delay = ErrorBackoff[Math.Min(failures, ErrorBackoff.Length - 1)];
failures++;
break;
}
// A wake cuts the wait short (credential refused, shared key refused).
await wake.WaitAsync(delay, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
finally
{
firstAttempt.TrySetResult();
}
}
private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed }
private async Task<EnrollOutcome> 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;
}
string nonce;
try
{
nonce = store.GetOrCreateEnrollNonce();
}
catch (Exception error)
{
logger.LogError(error, "Enrollment skipped: could not read or create enroll.json.");
return EnrollOutcome.Failed;
}
var request = new
{
hostname,
serialnumber = serial,
deviceid = ReadDeviceId(),
enrollnonce = nonce,
agentversion = agentVersion,
};
try
{
using var response = await client.PostAsJsonAsync("api/endpoints/enroll", request, token);
var body = await response.Content.ReadAsStringAsync(token);
if (response.StatusCode == HttpStatusCode.NotFound
&& AgentServerError.TryParse(body)?.Code is null or "" or "NOT_FOUND")
{
logger.LogInformation(
"Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.",
UnsupportedRetry);
return EnrollOutcome.Unsupported;
}
if (response.StatusCode == HttpStatusCode.Created)
{
var result = ParseResult(body);
var secret = result.GetString("credential");
if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal))
{
logger.LogError("Enrollment returned 201 without a usable credential.");
return EnrollOutcome.Failed;
}
var credential = new AgentCredential
{
Credential = secret,
AssetId = result.GetInt("assetid") ?? 0,
IssuedAt = DateTime.UtcNow.ToString("o"),
BaseUrl = config.BaseUrl.Trim().TrimEnd('/'),
};
try
{
credentials.Set(credential);
}
catch (Exception error)
{
// Shown once by the server; an admin must revoke and let it re-enroll.
logger.LogError(error,
"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.Enrolled;
}
if (response.StatusCode == HttpStatusCode.Accepted)
{
var result = ParseResult(body);
logger.LogInformation(
"Enrollment pending (request {RequestId}): {Reason}.",
result.GetInt("requestid"), result.GetString("reason") ?? "no reason given");
return EnrollOutcome.Pending;
}
var refusal = AgentServerError.TryParse(body);
if (refusal?.Code == "ENROLL_NONCE_MISMATCH")
{
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;
}
logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}",
(int)response.StatusCode, refusal?.Code, refusal?.Message);
return EnrollOutcome.Failed;
}
catch (Exception error) when (
error is HttpRequestException or TaskCanceledException or JsonException
&& !token.IsCancellationRequested)
{
logger.LogWarning("Enrollment request failed: {Message}", error.Message);
return EnrollOutcome.Failed;
}
}
// Accepts the bare body or the success envelope's data object.
private static ResultReader ParseResult(string body)
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
root = data;
return new ResultReader(root.Clone());
}
private readonly struct ResultReader
{
private readonly JsonElement root;
public ResultReader(JsonElement root) => this.root = root;
public string? GetString(string name)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() : null;
public int? GetInt(string name)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
&& value.TryGetInt32(out var number)
? number : null;
}
private string? ReadBiosSerial()
{
try
{
using var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS");
foreach (var item in searcher.Get())
{
var value = item["SerialNumber"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(value))
return value.Length > 128 ? value[..128] : value;
}
}
catch (Exception error)
{
logger.LogWarning(error, "WMI Win32_BIOS query failed");
}
return null;
}
private string? ReadDeviceId()
{
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
try
{
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
if (key?.GetValue(DeviceIdValue) is string value && !string.IsNullOrWhiteSpace(value))
return value.Trim();
}
catch (Exception error)
{
logger.LogDebug(error, "Registry read of {Value} in {View} view failed", DeviceIdValue, view);
}
}
return null;
}
}

View File

@@ -1,5 +1,4 @@
using System.Net; using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -10,9 +9,9 @@ namespace ShopDbEndpointAgent.Core;
// The server may publish site-wide alerts (maintenance windows, policy changes) // The server may publish site-wide alerts (maintenance windows, policy changes)
// or endpoint-specific warnings (outdated agent version, failed telemetry push). // or endpoint-specific warnings (outdated agent version, failed telemetry push).
// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle // The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle
// and deserializes the JSON response into FleetAlert DTOs. Shares the endpoints // and deserializes the JSON response into FleetAlert DTOs. Same auth as
// collector key via X-API-Key, so the alert channel rides the same kill switch as // telemetry and commands (AgentHttpFactory). In 1.20 the service calls this and
// telemetry and commands. // serves the tray over the local pipe (AgentPipeServer).
public sealed class FleetAlertClient : IDisposable public sealed class FleetAlertClient : IDisposable
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@@ -21,106 +20,30 @@ public sealed class FleetAlertClient : IDisposable
}; };
private readonly HttpClient http; private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger logger; private readonly ILogger logger;
public FleetAlertClient(AgentConfig config, ILogger logger) public FleetAlertClient(AgentHttpFactory httpFactory, ILogger logger)
{ {
this.logger = logger; this.logger = logger;
handler = new SocketsHttpHandler http = httpFactory.CreateClient(AgentHttpChannel.FleetAlert);
{
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
SslOptions = CreateSslOptions(config),
};
http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
} }
// Creates SslClientAuthenticationOptions with optional client certificate and // Tray compatibility until tray 1.7.0 moves to AgentPipeClient: shared key
// optional server certificate pinning. If ServerCertThumbprint is configured, // only, no credential. Remove with the last tray that calls it.
// validates the server's certificate matches exactly (certificate pinning). public FleetAlertClient(AgentConfig config, ILogger logger)
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config) : this(new AgentHttpFactory(config, new SharedKeyOnlyProvider(config), logger), logger)
{ {
var options = new System.Net.Security.SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
{
if (cert == null)
{
logger.LogWarning("Server provided no certificate");
return false;
}
// If server cert pinning is configured, validate thumbprint match
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
{
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
var actualThumbprint = x509Cert.GetCertHashString();
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
{
logger.LogError(
"Server certificate thumbprint mismatch on fleet alert channel. Expected {Expected}, got {Actual}",
expectedThumbprint, actualThumbprint);
return false;
}
logger.LogDebug("Server certificate validated via pinning on fleet alert channel: {Thumbprint}", actualThumbprint);
return true;
}
// No pinning configured: normal Windows chain validation
if (errors == System.Net.Security.SslPolicyErrors.None)
return true;
logger.LogWarning(
"Server certificate rejected on fleet alert channel: {Errors} ({Subject})",
errors, cert.Subject);
return false;
},
};
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
{
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
if (clientCert != null)
{
options.ClientCertificates = new X509CertificateCollection { clientCert };
logger.LogInformation(
"Attached client certificate to fleet alert channel: {Subject}",
clientCert.Subject);
}
}
return options;
} }
// Finds certificate in LocalMachine\My store by thumbprint. private sealed class SharedKeyOnlyProvider : ICredentialProvider
private X509Certificate2? FindClientCertificate(string thumbprint)
{ {
try public SharedKeyOnlyProvider(AgentConfig config)
{ => SharedKey = config.HasSharedKey() ? config.ApiKey : null;
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine); public AgentCredential? Current => null;
store.Open(OpenFlags.ReadOnly); public string? SharedKey { get; }
public event EventHandler? Changed { add { } remove { } }
var certs = store.Certificates.Find( public event EventHandler? EnrollmentNeeded { add { } remove { } }
X509FindType.FindByThumbprint, public void ReportRejected(string code, string? sentCredential) { }
thumbprint.Trim(),
validOnly: false);
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
}
catch
{
return null;
}
} }
public async Task<List<FleetAlert>> FetchAlertsAsync( public async Task<List<FleetAlert>> FetchAlertsAsync(
@@ -139,6 +62,9 @@ public sealed class FleetAlertClient : IDisposable
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>( var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
body, JsonOptions); body, JsonOptions);
if (envelope?.Data is FleetAlertList parsed)
foreach (var skipped in parsed.Skipped)
logger.LogWarning("Skipped malformed fleet alert: {Reason}", skipped);
return envelope?.Data ?? new List<FleetAlert>(); return envelope?.Data ?? new List<FleetAlert>();
} }

View File

@@ -15,14 +15,16 @@
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" /> <PackageReference Include="System.Management" Version="8.0.0" />
<!-- DPAPI LocalMachine for the per-PC credential (CredentialStore). -->
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" /> <PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
<!-- EventWaitHandleAcl: creating the user-context refresh event with an <!-- EventWaitHandleAcl: creating the user-context refresh event with an
explicit DACL. The service creates it as LocalSystem, and without a explicit DACL. The service creates it as LocalSystem, and without a
granted ACE the unelevated tray cannot open it. --> granted ACE the unelevated tray cannot open it. -->
<PackageReference Include="System.Threading.AccessControl" Version="8.0.0" /> <PackageReference Include="System.Threading.AccessControl" Version="8.0.0" />
<!-- FileSystem.AccessControl: setting ACLs on private key files for certificate <!-- FileSystem.AccessControl: protected DACLs on staging, credential and
renewal (File.GetAccessControl/SetAccessControl moved to this package in .NET 8+) --> appsettings.json files (ProtectedFileSystem, AgentPipeServer). -->
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" /> <PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
</ItemGroup> </ItemGroup>

View File

@@ -29,8 +29,12 @@ public sealed class TelemetryWorker : BackgroundService
private readonly CollectSignal? collectSignal; private readonly CollectSignal? collectSignal;
private readonly UserContextSpool? userContextSpool; private readonly UserContextSpool? userContextSpool;
private readonly UserContextRefreshChannel? userContextRefresh; private readonly UserContextRefreshChannel? userContextRefresh;
private readonly EnrollmentService? enrollment;
private readonly Random jitter = new(); private readonly Random jitter = new();
// Upper bound on holding first contact for the enrollment attempt.
private static readonly TimeSpan EnrollmentWaitLimit = TimeSpan.FromSeconds(20);
// How long to wait for the tray to answer a refresh request. // How long to wait for the tray to answer a refresh request.
// //
// Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a // Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a
@@ -51,8 +55,10 @@ public sealed class TelemetryWorker : BackgroundService
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger, AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null, IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null,
UserContextSpool? userContextSpool = null, UserContextSpool? userContextSpool = null,
UserContextRefreshChannel? userContextRefresh = null) UserContextRefreshChannel? userContextRefresh = null,
EnrollmentService? enrollment = null)
{ {
this.enrollment = enrollment;
this.collector = collector; this.collector = collector;
this.client = client; this.client = client;
this.config = config; this.config = config;
@@ -158,6 +164,18 @@ public sealed class TelemetryWorker : BackgroundService
} }
} }
if (enrollment is not null)
{
try
{
await enrollment.WaitForFirstAttemptAsync(EnrollmentWaitLimit, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
bool firstRun = true; bool firstRun = true;
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {

View File

@@ -5,7 +5,7 @@ using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray; namespace ShopDbEndpointAgent.Tray;
// Displays fleet-wide alert notifications from the ShopDB server in a toast-style // Displays fleet-wide alert notifications, relayed by the local agent, in a toast-style
// window. The form appears top-right, plays a system beep, and stays on top until // window. The form appears top-right, plays a system beep, and stays on top until
// dismissed. The left border is colored based on alert type (parsed from the // dismissed. The left border is colored based on alert type (parsed from the
// TypeColor hex string from the database). The operator acknowledges the alert by // TypeColor hex string from the database). The operator acknowledges the alert by
@@ -24,8 +24,7 @@ internal sealed class FleetAlertForm : Form
private static readonly Color DefaultTypeColor = Color.Gray; private static readonly Color DefaultTypeColor = Color.Gray;
private readonly FleetAlert alert; private readonly FleetAlert alert;
private readonly FleetAlertClient? client; private readonly AgentPipeClient? client;
private readonly string? hostname;
private readonly ILogger? logger; private readonly ILogger? logger;
private System.Windows.Forms.Timer? countdownTimer; private System.Windows.Forms.Timer? countdownTimer;
private System.Windows.Forms.Timer? statusCheckTimer; private System.Windows.Forms.Timer? statusCheckTimer;
@@ -40,11 +39,10 @@ internal sealed class FleetAlertForm : Form
public event EventHandler? OnAcknowledgeClicked; public event EventHandler? OnAcknowledgeClicked;
public FleetAlertForm( public FleetAlertForm(
FleetAlert alert, FleetAlertClient? client = null, string? hostname = null, ILogger? logger = null) FleetAlert alert, AgentPipeClient? client = null, ILogger? logger = null)
{ {
this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); this.alert = alert ?? throw new ArgumentNullException(nameof(alert));
this.client = client; this.client = client;
this.hostname = hostname;
this.logger = logger; this.logger = logger;
SuspendLayout(); SuspendLayout();
@@ -84,7 +82,7 @@ internal sealed class FleetAlertForm : Form
System.Media.SystemSounds.Beep.Play(); System.Media.SystemSounds.Beep.Play();
if (client != null && hostname != null) if (client != null)
{ {
StartStatusCheck(); StartStatusCheck();
} }
@@ -109,7 +107,7 @@ internal sealed class FleetAlertForm : Form
private void StatusCheckTimer_Tick(object? sender, EventArgs e) private void StatusCheckTimer_Tick(object? sender, EventArgs e)
{ {
if (client == null || hostname == null) if (client == null)
return; return;
var token = statusCheckCancellation.Token; var token = statusCheckCancellation.Token;
@@ -120,9 +118,9 @@ internal sealed class FleetAlertForm : Form
{ {
try try
{ {
var alerts = await client.FetchAlertsAsync(hostname, token) var alerts = await client.GetAlertsAsync(token)
.ConfigureAwait(false); .ConfigureAwait(false);
var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; var stillActive = alerts.Any(a => a.NotificationId == alert.NotificationId);
if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed) if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed)
{ {
@@ -139,7 +137,7 @@ internal sealed class FleetAlertForm : Form
} }
catch (Exception exception) catch (Exception exception)
{ {
// Keep the form open if status cannot be checked // Agent unreachable or refused: keep the form open
logger?.LogDebug(exception, logger?.LogDebug(exception,
"Status check for fleet alert {NotificationId} failed.", alert.NotificationId); "Status check for fleet alert {NotificationId} failed.", alert.NotificationId);
} }

View File

@@ -3,19 +3,21 @@ using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray; namespace ShopDbEndpointAgent.Tray;
// Polls the server for fleet-wide alerts every 60 seconds and displays new // Asks the local agent for fleet-wide alerts every 60 seconds and displays new
// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in // alerts to the operator via FleetAlertForm. The agent holds the credential and
// FleetAlertStore and posted back to the server so they do not reappear in // talks to the server; the tray only speaks the pipe. An alert is recorded in
// future polls. Runs for the tray's lifetime on a dedicated background thread. // FleetAlertStore only once the agent confirms the acknowledgement, so a failed
// ack lets it reappear. Runs for the tray's lifetime on a background thread.
internal sealed class FleetAlertWorker : IDisposable internal sealed class FleetAlertWorker : IDisposable
{ {
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60); private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15); private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
private readonly FleetAlertClient client; // Agent down polls every cycle; warn once, then at most this often.
private static readonly TimeSpan UnreachableLogInterval = TimeSpan.FromMinutes(15);
private readonly AgentPipeClient client;
private readonly FleetAlertStore store; private readonly FleetAlertStore store;
private readonly string hostname;
private readonly int assetId;
private readonly ILogger logger; private readonly ILogger logger;
private readonly SynchronizationContext? uiContext; private readonly SynchronizationContext? uiContext;
private readonly CancellationTokenSource cancellation; private readonly CancellationTokenSource cancellation;
@@ -24,18 +26,18 @@ internal sealed class FleetAlertWorker : IDisposable
// Set and cleared on the UI thread only. // Set and cleared on the UI thread only.
private FleetAlertForm? openForm; private FleetAlertForm? openForm;
// Poller thread only.
private DateTime lastUnreachableLogUtc = DateTime.MinValue;
private int unreachableCount;
public FleetAlertWorker( public FleetAlertWorker(
FleetAlertClient client, AgentPipeClient client,
FleetAlertStore store, FleetAlertStore store,
string hostname,
int assetId,
ILogger logger, ILogger logger,
SynchronizationContext? uiContext = null) SynchronizationContext? uiContext = null)
{ {
this.client = client ?? throw new ArgumentNullException(nameof(client)); this.client = client ?? throw new ArgumentNullException(nameof(client));
this.store = store ?? throw new ArgumentNullException(nameof(store)); this.store = store ?? throw new ArgumentNullException(nameof(store));
this.hostname = hostname ?? throw new ArgumentNullException(nameof(hostname));
this.assetId = assetId;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.uiContext = uiContext ?? SynchronizationContext.Current; this.uiContext = uiContext ?? SynchronizationContext.Current;
cancellation = new CancellationTokenSource(); cancellation = new CancellationTokenSource();
@@ -71,6 +73,10 @@ internal sealed class FleetAlertWorker : IDisposable
{ {
return; return;
} }
catch (AgentPipeException exception)
{
LogUnreachable(exception);
}
catch (Exception exception) catch (Exception exception)
{ {
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle."); logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
@@ -81,16 +87,35 @@ internal sealed class FleetAlertWorker : IDisposable
} }
} }
private void LogUnreachable(AgentPipeException exception)
{
unreachableCount++;
var now = DateTime.UtcNow;
if (now - lastUnreachableLogUtc < UnreachableLogInterval)
return;
lastUnreachableLogUtc = now;
logger.LogWarning(
"Could not get fleet alerts from the agent ({Count} failed poll(s)): {Error}. "
+ "Retrying on next cycle.", unreachableCount, exception.Message);
}
private void PollAndDisplayAlerts(CancellationToken token) private void PollAndDisplayAlerts(CancellationToken token)
{ {
var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult(); var alerts = client.GetAlertsAsync(token).GetAwaiter().GetResult();
if (unreachableCount > 0)
{
logger.LogInformation(
"Agent answered the alert poll again after {Count} failed poll(s).", unreachableCount);
unreachableCount = 0;
lastUnreachableLogUtc = DateTime.MinValue;
}
if (alerts is FleetAlertList { Skipped.Count: > 0 } parsed) if (alerts is FleetAlertList { Skipped.Count: > 0 } parsed)
{ {
logger.LogWarning( logger.LogWarning(
"Skipped {Count} malformed fleet alert(s): {Details}", "Skipped {Count} malformed fleet alert(s): {Details}",
parsed.Skipped.Count, string.Join("; ", parsed.Skipped)); parsed.Skipped.Count, string.Join("; ", parsed.Skipped));
} }
if (alerts is null || alerts.Count == 0) if (alerts.Count == 0)
{ {
logger.LogDebug("No active fleet alerts."); logger.LogDebug("No active fleet alerts.");
return; return;
@@ -129,7 +154,7 @@ internal sealed class FleetAlertWorker : IDisposable
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return; return;
using var form = new FleetAlertForm(alert, client, hostname, logger); using var form = new FleetAlertForm(alert, client, logger);
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token); form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
openForm = form; openForm = form;
try try
@@ -168,28 +193,45 @@ internal sealed class FleetAlertWorker : IDisposable
private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token) private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token)
{ {
logger.LogInformation( logger.LogInformation(
"Fleet alert {NotificationId} acknowledged; queuing server post.", alert.NotificationId); "Fleet alert {NotificationId} acknowledged; sending to the agent.", alert.NotificationId);
store.MarkAcknowledged(alert.NotificationId);
// Off the UI thread. Recorded locally only on Ok, so a failed ack
// shows the alert again on a later poll instead of losing it.
Task.Run(async () => Task.Run(async () =>
{ {
try try
{ {
await client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token); var response = await client.AckAlertAsync(alert.NotificationId, token);
logger.LogInformation("Fleet alert {NotificationId} posted to server.", alert.NotificationId); if (response.Ok)
{
store.MarkAcknowledged(alert.NotificationId);
logger.LogInformation("Fleet alert {NotificationId} acknowledged by the agent.",
alert.NotificationId);
}
else
{
logger.LogWarning(
"Agent did not acknowledge fleet alert {NotificationId}: {Error}. It may be shown again.",
alert.NotificationId, response.Error);
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
logger.LogInformation(
"Acknowledgement of fleet alert {NotificationId} cancelled by shutdown.",
alert.NotificationId);
} }
catch (Exception exception) catch (Exception exception)
{ {
logger.LogWarning(exception, logger.LogWarning(exception,
"Could not post acknowledgment for fleet alert {NotificationId}; already marked locally.", "Could not acknowledge fleet alert {NotificationId}; it may be shown again.",
alert.NotificationId); alert.NotificationId);
} }
}); });
} }
// Closes a displayed alert without acknowledging it. Call on the UI thread // Closes a displayed alert without acknowledging it, so the form's status
// before disposing the client, so the form's status check stops using it. // check stops polling the agent on shutdown.
public void CloseOpenAlert() public void CloseOpenAlert()
{ {
var form = openForm; var form = openForm;

View File

@@ -106,7 +106,18 @@
%LOCALAPPDATA%\ShopDB\FleetAlerts (the operator could not write the %LOCALAPPDATA%\ShopDB\FleetAlerts (the operator could not write the
ProgramData copy, so acks were lost every logon), merging the old file ProgramData copy, so acks were lost every logon), merging the old file
once. Shutdown no longer blocks on the poller's sleeps. --> once. Shutdown no longer blocks on the poller's sleeps. -->
<Version>1.6.11</Version> <!-- 1.7.0: the tray holds no credential. Fleet alerts, alert acknowledgement,
Send Status and Report an issue all go through the SYSTEM agent's local
pipe (ShopDB.EndpointAgent.v1) instead of reading the collector key and
config or shelling the agent exe with the once and report verbs, so a
standard user on the bay can no longer lift the fleet key through the
tray's access path. The state.json asset-id reader is gone (the agent
knows its own identity). An unreachable agent shows the agent-not-running
balloon, and alert polling logs at a throttled rate and retries. An alert
is recorded as acknowledged only when the agent confirms it, so a failed
ack lets it reappear. The collect-usercontext probe is unchanged.
Requires the agent release that hosts the pipe. -->
<Version>1.7.0</Version>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>

View File

@@ -11,11 +11,11 @@ namespace ShopDbEndpointAgent.Tray;
// and headless-friendly: no main window, the icon is the whole UI surface until // and headless-friendly: no main window, the icon is the whole UI surface until
// the operator opens the ticket dialog. // the operator opens the ticket dialog.
// //
// "Send Status now" reuses the exact production collection path by shelling out // "Send Status now", "Report an issue" and fleet alerts all go through the
// to the installed shopdbagent.exe with --once. That keeps the collector key and // SYSTEM agent's local pipe (AgentPipeClient). The agent holds the credential
// all collection logic in the service binary: the tray never holds a secret and // and all collection logic: the tray never reads a key or config and never
// never duplicates wiring, it just asks the agent to report on demand so the // duplicates wiring, it just asks the agent to act on demand so the ShopDB
// ShopDB monitor page reflects the box immediately instead of at next cadence. // monitor page reflects the box immediately instead of at next cadence.
internal sealed class TrayContext : ApplicationContext internal sealed class TrayContext : ApplicationContext
{ {
// The headless agent/service executable. The tray ships next to it in the // The headless agent/service executable. The tray ships next to it in the
@@ -27,6 +27,14 @@ internal sealed class TrayContext : ApplicationContext
private readonly Icon brandIcon; private readonly Icon brandIcon;
private bool sending; private bool sending;
private const string AgentUnreachableText =
"Could not reach the ShopDB Endpoint Agent. Is the ShopDB Endpoint Agent service running?";
private readonly AgentPipeClient pipeClient = new();
// Cancels in-flight pipe calls from menu handlers when the tray exits.
private readonly CancellationTokenSource shutdownCancellation = new();
// Session-scoped collection. The service is LocalSystem and cannot see the // Session-scoped collection. The service is LocalSystem and cannot see the
// operator's mapped drives, user-owned listeners, or input idle time, so the // operator's mapped drives, user-owned listeners, or input idle time, so the
// tray - which is already the operator - collects them and leaves the result // tray - which is already the operator - collects them and leaves the result
@@ -82,11 +90,10 @@ internal sealed class TrayContext : ApplicationContext
// guard and launch two probes at the same spool file. // guard and launch two probes at the same spool file.
private readonly SynchronizationContext? uiContext; private readonly SynchronizationContext? uiContext;
// Fleet alerts are pulled from the server and displayed to the operator as // Fleet alerts are fetched through the agent pipe and displayed to the
// toast notifications in the top-right corner. The worker polls every 60 // operator as toast notifications in the top-right corner. The worker polls
// seconds, filters out already-acknowledged alerts via the store, and posts // every 60 seconds, filters out already-acknowledged alerts via the store,
// acknowledgements back to the server when the operator dismisses the form. // and sends acknowledgements to the agent when the operator dismisses the form.
private readonly FleetAlertClient? fleetAlertClient;
private readonly FleetAlertStore? fleetAlertStore; private readonly FleetAlertStore? fleetAlertStore;
private readonly FleetAlertWorker? fleetAlertWorker; private readonly FleetAlertWorker? fleetAlertWorker;
@@ -175,27 +182,16 @@ internal sealed class TrayContext : ApplicationContext
UserContextInterval); UserContextInterval);
} }
// Fleet alert worker. Best-effort: if the config cannot be loaded or the // Fleet alert worker. Best-effort: if the worker fails to start, the tray
// worker fails to start, the tray still runs and the other features work. // still runs and the other features work. An agent that is down is not a
// The operator just does not receive fleet-wide alerts. Loading the config // startup failure; the worker retries the pipe every cycle.
// requires reading %ProgramData%\ShopDB\config.json, which is written by
// the installer and owned by SYSTEM but readable by the operator. Loading
// the asset ID requires reading the spool file written by the service.
try try
{ {
var config = AgentConfigProvider.Load(logger: logger);
var assetId = ResolveAssetId();
var hostname = Environment.MachineName;
fleetAlertClient = new FleetAlertClient(config,
logProvider.CreateLogger(nameof(FleetAlertClient)));
fleetAlertStore = new FleetAlertStore( fleetAlertStore = new FleetAlertStore(
logProvider.CreateLogger(nameof(FleetAlertStore))); logProvider.CreateLogger(nameof(FleetAlertStore)));
fleetAlertWorker = new FleetAlertWorker( fleetAlertWorker = new FleetAlertWorker(
fleetAlertClient, pipeClient,
fleetAlertStore, fleetAlertStore,
hostname,
assetId,
logProvider.CreateLogger(nameof(FleetAlertWorker)), logProvider.CreateLogger(nameof(FleetAlertWorker)),
uiContext); uiContext);
fleetAlertWorker.Start(); fleetAlertWorker.Start();
@@ -390,13 +386,7 @@ internal sealed class TrayContext : ApplicationContext
includeScreenshot = dialog.IncludeScreenshot; includeScreenshot = dialog.IncludeScreenshot;
} }
var agentExe = ResolveAgentExe(); var token = shutdownCancellation.Token;
if (agentExe is null)
{
ShowBalloon("Report an issue", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
// Capture AFTER the dialog closes so the report form is never in the // Capture AFTER the dialog closes so the report form is never in the
// shot. Stage into the interactive operator's own profile // shot. Stage into the interactive operator's own profile
@@ -426,16 +416,23 @@ internal sealed class TrayContext : ApplicationContext
ShowBalloon("Report an issue", "Sending your report to ShopDB...", ShowBalloon("Report an issue", "Sending your report to ShopDB...",
ToolTipIcon.Info); ToolTipIcon.Info);
int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\""); // The agent checks the path is inside this user's own profile.
if (exitCode == 0) var response = await pipeClient.SendReportAsync(payloadPath, token);
if (response.Ok)
ShowBalloon("Report an issue", "Your report was sent to ShopDB.", ShowBalloon("Report an issue", "Your report was sent to ShopDB.",
ToolTipIcon.Info); ToolTipIcon.Info);
else if (response.Data is null)
ShowBalloon("Report an issue", AgentUnreachableText, ToolTipIcon.Error);
else else
ShowBalloon("Report an issue", ShowBalloon("Report an issue",
"Report send failed (exit " + exitCode + "Report send failed (" + response.Error +
"). See the agent log under ProgramData\\ShopDB.", "). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning); ToolTipIcon.Warning);
} }
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Tray is exiting.
}
catch (Exception error) catch (Exception error)
{ {
ShowBalloon("Report an issue", "Could not send the report: " + ShowBalloon("Report an issue", "Could not send the report: " +
@@ -474,23 +471,15 @@ internal sealed class TrayContext : ApplicationContext
} }
} }
// Runs shopdbagent.exe --once out of process so the operator can push a // Asks the agent over the pipe to run telemetry once so the operator can
// fresh report without waiting for the next cadence tick. The exit code is // push a fresh report without waiting for the next cadence tick. The agent
// the agent's own contract: 0 = reported OK, non-zero = failed (bad key, // answers ok or an error; the detail is in the rolling agent log.
// host removed, network) and the detail is in the rolling agent log.
private async void OnSendStatus(object? sender, EventArgs e) private async void OnSendStatus(object? sender, EventArgs e)
{ {
if (sending) if (sending)
return; return;
var agentExe = ResolveAgentExe(); var token = shutdownCancellation.Token;
if (agentExe is null)
{
ShowBalloon("Send Status", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
sending = true; sending = true;
trayIcon.Text = "ShopDB Endpoint Agent (sending status...)"; trayIcon.Text = "ShopDB Endpoint Agent (sending status...)";
ShowBalloon("Send Status", "Sending this PC's status to ShopDB...", ShowBalloon("Send Status", "Sending this PC's status to ShopDB...",
@@ -500,7 +489,7 @@ internal sealed class TrayContext : ApplicationContext
{ {
// Probe BEFORE reporting, and await it. The operator clicking this // Probe BEFORE reporting, and await it. The operator clicking this
// has just changed something - remapped a drive, signed in - and // has just changed something - remapped a drive, signed in - and
// expects the result to show up. --once reads the spool file, so a // expects the result to show up. sendstatus reads the spool file, so a
// probe started after it, or not awaited, would publish the previous // probe started after it, or not awaited, would publish the previous
// probe's answer and the operator would click again and see the same // probe's answer and the operator would click again and see the same
// stale card. // stale card.
@@ -510,25 +499,33 @@ internal sealed class TrayContext : ApplicationContext
// this button is for. // this button is for.
await CollectUserContextAsync(); await CollectUserContextAsync();
int exitCode = await RunAgentAsync(agentExe, "--once"); var response = await pipeClient.SendStatusAsync(token);
if (exitCode == 0) if (response.Ok)
ShowBalloon("Send Status", "Status sent to ShopDB.", ShowBalloon("Send Status", "Status sent to ShopDB.",
ToolTipIcon.Info); ToolTipIcon.Info);
else if (response.Data is null)
ShowBalloon("Send Status", AgentUnreachableText, ToolTipIcon.Error);
else else
ShowBalloon("Send Status", ShowBalloon("Send Status",
"Status send failed (exit " + exitCode + "Status send failed (" + response.Error +
"). See the agent log under ProgramData\\ShopDB.", "). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning); ToolTipIcon.Warning);
} }
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
// Tray is exiting.
return;
}
catch (Exception error) catch (Exception error)
{ {
ShowBalloon("Send Status", "Could not run the agent: " + ShowBalloon("Send Status", "Could not reach the agent: " +
error.Message, ToolTipIcon.Error); error.Message, ToolTipIcon.Error);
} }
finally finally
{ {
sending = false; sending = false;
trayIcon.Text = "ShopDB Endpoint Agent"; if (!token.IsCancellationRequested)
trayIcon.Text = "ShopDB Endpoint Agent";
} }
} }
@@ -682,36 +679,6 @@ internal sealed class TrayContext : ApplicationContext
ExitThread(); ExitThread();
} }
// Resolves this machine's asset ID from the telemetry state file written by
// the service. Used to identify which asset acknowledged a fleet alert. If
// the state file is missing or unreadable, returns 0 as a signal that fleet
// alerts should be disabled (the asset is not enrolled).
private int ResolveAssetId()
{
try
{
var stateFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "state.json");
if (!File.Exists(stateFile))
return 0;
var json = File.ReadAllText(stateFile);
var state = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json);
if (state is not null && state.TryGetValue("assetId", out var value))
{
if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number)
return element.GetInt32();
}
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not resolve asset ID from state file.");
}
return 0;
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)
@@ -725,8 +692,9 @@ internal sealed class TrayContext : ApplicationContext
refreshCancellation.Cancel(); refreshCancellation.Cancel();
refreshCancellation.Dispose(); refreshCancellation.Dispose();
shutdownCancellation.Cancel();
fleetAlertWorker?.Dispose(); fleetAlertWorker?.Dispose();
fleetAlertClient?.Dispose(); shutdownCancellation.Dispose();
trayIcon.Dispose(); trayIcon.Dispose();
brandIcon.Dispose(); brandIcon.Dispose();

View File

@@ -11,12 +11,13 @@ using ShopDbEndpointAgent.Core;
// --console run interactively in the foreground for debugging // --console run interactively in the foreground for debugging
// --once collect and report exactly once, then exit (smoke test / cron) // --once collect and report exactly once, then exit (smoke test / cron)
// --report <path> upload one operator issue report (JSON sidecar written by // --report <path> upload one operator issue report (JSON sidecar written by
// the tray), then exit. Keeps the collector key in the // the tray), then exit.
// service so the tray never holds a secret. // Not running as SYSTEM, --once and --report are pipe clients: they ask the
// running service (AgentPipeServer) to do the work and never load a secret.
// //
// Secrets are never read here: AgentConfigProvider layers the registry // Secrets are never read here: AgentConfigProvider layers the registry
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret // (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
// appsettings.json. The executable is intentionally named shopdbagent.exe so it // appsettings.json, and the per-PC credential lives in CredentialStore. The executable is intentionally named shopdbagent.exe so it
// never collides with third-party agent.exe processes/services (for example the // never collides with third-party agent.exe processes/services (for example the
// MTConnect agent this same build can monitor). // MTConnect agent this same build can monitor).
@@ -121,6 +122,40 @@ static bool IsProcessElevated()
} }
} }
// Not SYSTEM: a user (the tray, or an operator at a prompt) cannot read the
// credential or the key, so hand the request to the service over the pipe.
// Ahead of the bootstrap logger for the same reason as the probe above.
if ((runOnce || !string.IsNullOrWhiteSpace(reportPayloadPath)) && !IsRunningAsSystem())
return await RunPipeClientAsync(runOnce, reportPayloadPath);
static bool IsRunningAsSystem()
{
if (!OperatingSystem.IsWindows())
return true;
try
{
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
return identity.IsSystem;
}
catch
{
return false;
}
}
// Exit code is the tray's contract: 0 = done, non-zero = failed (reason on stderr).
static async Task<int> RunPipeClientAsync(bool runOnce, string? reportPath)
{
var client = new AgentPipeClient();
var response = runOnce
? await client.SendStatusAsync(CancellationToken.None)
: await client.SendReportAsync(Path.GetFullPath(reportPath!), CancellationToken.None);
if (response.Ok)
return 0;
Console.Error.WriteLine("shopdbagent: " + response.Error);
return response.Data is null ? 3 : 1;
}
// Bootstrap logger first so config loading (including the registry read that // Bootstrap logger first so config loading (including the registry read that
// CyberArk/EPM may gate) is fully diagnosable before the host starts. // CyberArk/EPM may gate) is fully diagnosable before the host starts.
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory()); using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
@@ -136,15 +171,24 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
? FileLoggerProvider.DefaultDirectory() ? FileLoggerProvider.DefaultDirectory()
: config.LogPath; : config.LogPath;
if (!config.IsUsable()) // BaseUrl is the only hard requirement: an enrolled PC runs without the shared
// key, and an unenrolled one can still request approval without it.
if (string.IsNullOrWhiteSpace(config.BaseUrl))
{ {
bootstrapLogger.LogError( bootstrapLogger.LogError(
"Config incomplete: provision CollectorKey and BaseUrl in " + "Config incomplete: provision BaseUrl in " +
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " + "HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl in appsettings.json for dev). " +
"See the registry lines above to tell 'missing' from 'access denied'."); "See the registry lines above to tell 'missing' from 'access denied'.");
return 1; return 1;
} }
if (!config.HasSharedKey())
{
bootstrapLogger.LogWarning(
"No CollectorKey provisioned. Fine once this PC is enrolled; until then " +
"requests are unauthenticated and enrollment needs admin approval.");
}
var baseUrlProblem = config.BaseUrlProblem(); var baseUrlProblem = config.BaseUrlProblem();
if (baseUrlProblem is not null) if (baseUrlProblem is not null)
{ {
@@ -171,53 +215,6 @@ catch (Exception error)
static async Task<int> RunHostAsync(AgentConfig config, string agentVersion, static async Task<int> RunHostAsync(AgentConfig config, string agentVersion,
string logDirectory, bool forceConsole) string logDirectory, bool forceConsole)
{ {
// Non-zero on purpose: SCM recovery only restarts a service that exits with
// a failure. Exit 0 after a renewal left the agent stopped until reboot.
const int RestartForRenewedCertificate = 3;
// Well inside the SCM 30s start timeout. The host has not reported
// SERVICE_RUNNING yet, so a slow or unreachable server here would otherwise
// get the whole start killed.
var renewalBudget = TimeSpan.FromSeconds(15);
// Certificate renewal check: if client cert expires within 30 days, request
// renewal from server, install new cert, and exit so service manager
// restarts us. This runs BEFORE the host starts so a renewed cert is picked up.
var renewalLogger = LoggerFactory.Create(logging =>
ConfigureLogging(logging, logDirectory)).CreateLogger<CertificateRenewal>();
var renewal = new CertificateRenewal(renewalLogger, config);
try
{
// Task.Run so a slow synchronous prefix cannot dodge the budget either.
var renewalTask = Task.Run(renewal.CheckAndRenewIfNeeded);
if (await Task.WhenAny(renewalTask, Task.Delay(renewalBudget)) != renewalTask)
{
renewalLogger.LogWarning(
"Certificate renewal check still running after {Budget}; starting agent " +
"with existing certificate. The expiry monitor retries later.",
renewalBudget);
}
else if (await renewalTask)
{
renewalLogger.LogInformation(
"Certificate renewed successfully. Service will exit with code {Code} " +
"so service manager recovery restarts it with the new certificate.",
RestartForRenewedCertificate);
// Return without starting the host. The new process picks up the
// updated thumbprint from appsettings.json. Safer than
// Environment.Exit, which aborts in-flight work.
return RestartForRenewedCertificate;
}
}
catch (Exception ex)
{
renewalLogger.LogError(ex,
"Certificate renewal check failed. Starting agent with existing certificate.");
// Continue startup with existing (possibly expiring) certificate rather
// than blocking service start. Next startup will retry renewal.
}
var builder = Host.CreateApplicationBuilder(); var builder = Host.CreateApplicationBuilder();
if (!forceConsole) if (!forceConsole)
@@ -226,16 +223,43 @@ static async Task<int> RunHostAsync(AgentConfig config, string agentVersion,
ConfigureLogging(builder.Logging, logDirectory); ConfigureLogging(builder.Logging, logDirectory);
RegisterCoreServices(builder.Services, config, agentVersion); RegisterCoreServices(builder.Services, config, agentVersion);
// Enrollment is registered (and so started) first; the workers wait briefly
// for its first attempt so an enrollable PC reports on its own credential.
builder.Services.AddSingleton(sp => new EnrollmentService(
config,
sp.GetRequiredService<AgentHttpFactory>(),
sp.GetRequiredService<AgentCredentialProvider>(),
sp.GetRequiredService<CredentialStore>(),
agentVersion,
sp.GetRequiredService<ILogger<EnrollmentService>>()));
builder.Services.AddSingleton(sp => new AgentPipeServer(
config,
sp.GetRequiredService<TelemetryWorker>(),
sp.GetRequiredService<CollectorClient>(),
sp.GetRequiredService<FleetAlertClient>(),
sp.GetRequiredService<ICredentialProvider>(),
agentVersion,
sp.GetRequiredService<ILogger<AgentPipeServer>>()));
builder.Services.AddHostedService(sp => sp.GetRequiredService<EnrollmentService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<AgentPipeServer>());
// Background monitor for certificate expiry (critical for long-running agents) var host = builder.Build();
// Checks every 24 hours, triggers renewal if < 30 days remaining
// Without this, agents running > 30 days would have expired certs
builder.Services.AddSingleton<CertificateExpiryMonitor>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CertificateExpiryMonitor>());
await builder.Build().RunAsync(); // A newly issued credential reports straight away rather than at the next
// cadence tick, so the server sees the PC switch over.
var credentials = host.Services.GetRequiredService<AgentCredentialProvider>();
var collectSignal = host.Services.GetRequiredService<CollectSignal>();
credentials.Changed += (_, _) =>
{
if (credentials.Current is not null)
collectSignal.Request();
};
await host.RunAsync();
return 0; return 0;
} }
@@ -259,8 +283,8 @@ static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
} }
// Uploads one operator issue report described by a tray-written JSON sidecar, // Uploads one operator issue report described by a tray-written JSON sidecar,
// then deletes the payload folder. Runs out of process from the tray so the // then deletes the sidecar. SYSTEM only; a user-context --report goes through
// collector key stays in the service binary. The exit code is the tray's // the pipe (RunPipeClientAsync) and the service validates the path. The exit code is the tray's
// contract: 0 = uploaded, non-zero = failed (detail in the agent log). // contract: 0 = uploaded, non-zero = failed (detail in the agent log).
static async Task<int> RunReportAsync(AgentConfig config, string payloadPath, static async Task<int> RunReportAsync(AgentConfig config, string payloadPath,
string logDirectory, AgentFaultMonitor faultMonitor) string logDirectory, AgentFaultMonitor faultMonitor)
@@ -371,8 +395,28 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
sp.GetRequiredService<EventWatermarkStore>(), sp.GetRequiredService<EventWatermarkStore>(),
sp.GetRequiredService<UserContextSpool>())); sp.GetRequiredService<UserContextSpool>()));
// Per-PC credential (if enrolled) or shared key, swapped at runtime. One
// factory builds every HttpClient so pins and auth cannot be skipped.
services.AddSingleton(sp => new CredentialStore(
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Credentials")));
services.AddSingleton(sp => new AgentCredentialProvider(
config,
sp.GetRequiredService<CredentialStore>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Credentials")));
services.AddSingleton<ICredentialProvider>(sp =>
sp.GetRequiredService<AgentCredentialProvider>());
services.AddSingleton(sp => new AgentHttpFactory(
config,
sp.GetRequiredService<ICredentialProvider>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Http")));
services.AddSingleton(sp => new CollectorClient( services.AddSingleton(sp => new CollectorClient(
config, sp.GetRequiredService<ILogger<CollectorClient>>())); sp.GetRequiredService<AgentHttpFactory>(),
sp.GetRequiredService<ILogger<CollectorClient>>()));
services.AddSingleton(sp => new FleetAlertClient(
sp.GetRequiredService<AgentHttpFactory>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(FleetAlertClient))));
services.AddSingleton(sp => new TelemetryWorker( services.AddSingleton(sp => new TelemetryWorker(
sp.GetRequiredService<ICollector>(), sp.GetRequiredService<ICollector>(),
@@ -383,24 +427,28 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
sp.GetService<IAgentUtilityRunner>(), sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<CollectSignal>(), sp.GetRequiredService<CollectSignal>(),
sp.GetRequiredService<UserContextSpool>(), sp.GetRequiredService<UserContextSpool>(),
sp.GetRequiredService<UserContextRefreshChannel>())); sp.GetRequiredService<UserContextRefreshChannel>(),
sp.GetService<EnrollmentService>()));
// Command channel: outbound long-poll for allowlisted WebUI commands, run // Command channel: outbound long-poll for allowlisted WebUI commands, run
// through the fail-closed executor. Hosted alongside telemetry but on its // through the fail-closed executor. Hosted alongside telemetry but on its
// own loop so a held-open poll never delays a report. // own loop so a held-open poll never delays a report.
services.AddSingleton(sp => new CommandClient( services.AddSingleton(sp => new CommandClient(
config, sp.GetRequiredService<ILogger<CommandClient>>())); sp.GetRequiredService<AgentHttpFactory>(),
sp.GetRequiredService<ILogger<CommandClient>>()));
services.AddSingleton(sp => new CommandExecutor( services.AddSingleton(sp => new CommandExecutor(
sp.GetRequiredService<ILogger<CommandExecutor>>(), sp.GetRequiredService<ILogger<CommandExecutor>>(),
config)); config,
sp.GetRequiredService<AgentHttpFactory>()));
services.AddSingleton(sp => new CommandWorker( services.AddSingleton(sp => new CommandWorker(
sp.GetRequiredService<CommandClient>(), sp.GetRequiredService<CommandClient>(),
sp.GetRequiredService<CommandExecutor>(), sp.GetRequiredService<CommandExecutor>(),
sp.GetRequiredService<ILogger<CommandWorker>>(), sp.GetRequiredService<ILogger<CommandWorker>>(),
sp.GetRequiredService<CollectSignal>(), sp.GetRequiredService<CollectSignal>(),
config)); config,
sp.GetService<EnrollmentService>()));
} }
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)

View File

@@ -265,7 +265,27 @@
material and reparse points, and rechecks the handle's final path. material and reparse points, and rechecks the handle's final path.
Certificate renewal at start is bounded to 15s and exits non-zero so Certificate renewal at start is bounded to 15s and exits non-zero so
SCM recovery restarts the service. --> SCM recovery restarts the service. -->
<Version>1.19.0</Version> <!-- 1.20.0: per-PC endpoint credentials. The shared fleet collector key was
readable by any standard user on every bay, so one bay leaked the whole
fleet. The agent now enrolls itself (POST /api/endpoints/enroll with
hostname, BIOS serial, DeviceId and a persisted nonce) and receives its
own credential, stored DPAPI LocalMachine under a SYSTEM/Administrators
directory and sent as X-Endpoint-Credential. The shared key stays the
fallback until the PC is enrolled, so the build also runs against an
older server (a 404 on enroll parks enrollment for 6 hours). A revoked
credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without
a restart. Server trust moves to SPKI SHA-256 pins (ServerPins registry
value and serverpins in appsettings.json), checked by one AgentHttpFactory
that now builds every HttpClient, including script and package downloads
that previously had their own unpinned handlers. The tray holds no
secret any more: it reaches the service over the local named pipe
ShopDB.EndpointAgent.v1 (ping, sendstatus, sendreport, alerts, ackalert),
and the once and report switches become pipe clients when not SYSTEM. A report path must sit inside the
caller's own profile. appsettings.json is restricted to SYSTEM and
Administrators once the pipe is up. The 1.19 client-certificate renewal
and expiry monitor are removed, and with them the non-zero exit at start.
Pairs with tray 1.7.0. -->
<Version>1.20.0</Version>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>

View File

@@ -1,6 +1,7 @@
{ {
"baseurl": "", "baseurl": "",
"apikey": "REPLACE_WITH_COLLECTOR_KEY", "apikey": "REPLACE_WITH_COLLECTOR_KEY",
"serverpins": [],
"cadenceseconds": 900, "cadenceseconds": 900,
"jitterseconds": 60, "jitterseconds": 60,
"startupjitterseconds": 30, "startupjitterseconds": 30,