diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 053ce0f3..39f00a68 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -13,19 +13,17 @@ public sealed class AgentConfig [JsonPropertyName("apikey")] public string ApiKey { get; set; } = ""; [JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900; - // Optional client certificate thumbprint for mTLS authentication. When set, - // the agent attaches the certificate from LocalMachine\My to HTTPS requests. - // The server validates the fingerprint against its database. Empty = use - // 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). + // Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no + // separators). Still honoured next to ServerPins; breaks on every cert + // renewal, so prefer an SPKI pin. See AgentHttpFactory. [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 ServerPins { get; set; } = new(); + // Jitter spreads a fleet's reports so they do not arrive in lockstep // (thundering herd). Actual delay = cadence +/- up to jitterseconds. [JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60; @@ -159,8 +157,12 @@ public sealed class AgentConfig public List ApprovedTaskPaths { get; set; } = new(); public bool IsUsable() - => !string.IsNullOrWhiteSpace(BaseUrl) - && !string.IsNullOrWhiteSpace(ApiKey) + => !string.IsNullOrWhiteSpace(BaseUrl) && HasSharedKey(); + + // 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"; // Validates the BaseUrl scheme before any request is made. A plaintext diff --git a/agent/ShopDbEndpointAgent.Core/AgentCredentials.cs b/agent/ShopDbEndpointAgent.Core/AgentCredentials.cs new file mode 100644 index 00000000..92863141 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentCredentials.cs @@ -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(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(); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs b/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs new file mode 100644 index 00000000..c19dca3e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs @@ -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 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 spkiPins; + private readonly string? legacyThumbprint; + private readonly ILogger logger; + + private ServerPinSet(HashSet 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(StringComparer.Ordinal); + foreach (var pin in config.ServerPins ?? new List()) + 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 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 ReadRegistryPins(ILogger logger) + { + var found = new List(); + 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; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs b/agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs new file mode 100644 index 00000000..2b3c2bc1 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs @@ -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 PingAsync(CancellationToken cancellationToken) + => SendSafeAsync(new { op = "ping" }, ShortOperationTimeout, cancellationToken); + + public Task SendStatusAsync(CancellationToken cancellationToken) + => SendSafeAsync(new { op = "sendstatus" }, LongOperationTimeout, cancellationToken); + + public Task SendReportAsync(string spoolPath, CancellationToken cancellationToken) + => SendSafeAsync(new { op = "sendreport", spoolpath = spoolPath }, LongOperationTimeout, + cancellationToken); + + public async Task> 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>(AlertOptions) ?? new List(); + } + catch (JsonException error) + { + throw new AgentPipeException("agent alerts could not be parsed", error); + } + } + + public Task AckAlertAsync(int notificationId, CancellationToken cancellationToken) + => SendSafeAsync(new { op = "ackalert", notificationid = notificationId }, + ShortOperationTimeout, cancellationToken); + + private static async Task 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 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 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); + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs b/agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs new file mode 100644 index 00000000..f428028a --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs @@ -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 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 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 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 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( + 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 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 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 SafeDelay(TimeSpan delay, CancellationToken token) + { + try + { + await Task.Delay(delay, token); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } +} diff --git a/agent/ShopDbEndpointAgent.Core/CertificateExpiryMonitor.cs b/agent/ShopDbEndpointAgent.Core/CertificateExpiryMonitor.cs deleted file mode 100644 index 0bfca037..00000000 --- a/agent/ShopDbEndpointAgent.Core/CertificateExpiryMonitor.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ShopDbEndpointAgent.Core; - -/// -/// 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. -/// -public class CertificateExpiryMonitor : BackgroundService -{ - private readonly ILogger logger; - private readonly ILogger 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 logger, - ILogger 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(); - } - } -} diff --git a/agent/ShopDbEndpointAgent.Core/CertificateRenewal.cs b/agent/ShopDbEndpointAgent.Core/CertificateRenewal.cs deleted file mode 100644 index c5c8a2f9..00000000 --- a/agent/ShopDbEndpointAgent.Core/CertificateRenewal.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -public class CertificateRenewal -{ - private readonly ILogger logger; - private readonly AgentConfig config; - - private const int RENEWAL_THRESHOLD_DAYS = 30; - private const string RENEWAL_ENDPOINT = "/api/endpoints/certificate/renew"; - - public CertificateRenewal(ILogger logger, AgentConfig config) - { - this.logger = logger; - this.config = config; - } - - /// - /// Check if client certificate needs renewal (expires in < 30 days). - /// Called on agent startup before first telemetry report. - /// - public async Task 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 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(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; } - } -} diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs index 5789b617..3ea688da 100644 --- a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -1,13 +1,13 @@ using System.Net; using System.Net.Http.Json; -using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Microsoft.Extensions.Logging; namespace ShopDbEndpointAgent.Core; -// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the -// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping +// Posts one EndpointReport to POST {baseurl}/api/endpoints/collect (per-PC +// 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. // // Resilience: transient failures (network errors, timeouts, 5xx, 429) are @@ -28,124 +28,15 @@ public sealed class CollectorClient : IDisposable private readonly int maxAttempts; private readonly Random jitter = new(); - public CollectorClient(AgentConfig config, ILogger logger, + private readonly ICredentialProvider credentials; + + public CollectorClient(AgentHttpFactory httpFactory, ILogger logger, int maxAttempts = 4) { this.logger = logger; this.maxAttempts = Math.Max(1, maxAttempts); - - // Create HttpClient with optional client certificate for mTLS - 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; - } + credentials = httpFactory.Credentials; + http = httpFactory.CreateClient(AgentHttpChannel.Collector); } public async Task SendAsync( @@ -184,8 +75,12 @@ public sealed class CollectorClient : IDisposable private async Task SendOnceAsync( EndpointReport report, CancellationToken token) { - using var response = await http.PostAsJsonAsync( - "api/collector/endpoints", report, JsonOptions, token); + // Enrolled agents post to the credential route; the shared key keeps + // 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); if (!response.IsSuccessStatusCode) @@ -199,7 +94,7 @@ public sealed class CollectorClient : IDisposable // Fetch the server-resolved watch list (policy-attached processes/services) // 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 // blanking monitoring on a transient blip. A single attempt, no retry - the // 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 // optional PNG screenshot) to POST /api/endpoints/operatorreports as - // multipart/form-data, authorized by the same X-API-Key collector key as - // telemetry. The server resolves this hostname to its asset. A single + // multipart/form-data, authorized the same way as telemetry. The server + // resolves this hostname (or the credential) to its asset. A single // 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 // clear failure. Returns the parsed upload response (new row id) or throws. public async Task UploadOperatorReportAsync( string reason, string details, string? screenshotPath, string hostname, 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 UploadOperatorReportAsync( + string reason, string details, Stream? screenshot, string? screenshotName, + string hostname, CancellationToken token) { using var form = new MultipartFormDataContent(); form.Add(new StringContent(reason ?? string.Empty), "reason"); form.Add(new StringContent(details ?? string.Empty), "details"); form.Add(new StringContent(hostname ?? string.Empty), "hostname"); - StreamContent? imageContent = null; - FileStream? imageStream = null; - try + if (screenshot is not null) { - if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) - { - imageStream = File.OpenRead(screenshotPath); - imageContent = new StreamContent(imageStream); - imageContent.Headers.ContentType = - 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( - body, JsonOptions); - return envelope?.Data; - } - finally - { - imageContent?.Dispose(); - imageStream?.Dispose(); + // Not disposed with the form: the caller owns the stream. + var imageContent = new StreamContent(new NonClosingStream(screenshot)); + imageContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + form.Add(imageContent, "screenshot", screenshotName ?? "screenshot.png"); } + + 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( + 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 ReadAsync(Memory 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(); @@ -301,9 +224,15 @@ public sealed class CollectorException : Exception StatusCode >= 400 && StatusCode < 500 && 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) : base($"collector returned HTTP {statusCode}: {body}") { StatusCode = statusCode; + Body = body; } } diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs index e27c31e5..c2f0c7bf 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -11,9 +10,8 @@ namespace ShopDbEndpointAgent.Core; // 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 // claimed command locally the agent posts the outcome to -// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector -// key via X-API-Key, so the command channel rides the same kill switch as -// telemetry and adds no second secret. +// POST /api/endpoints/commands/{id}/result. Both use the same auth as telemetry +// (per-PC credential, else the shared collector key), added by AgentHttpFactory. public sealed class CommandClient : IDisposable { private static readonly JsonSerializerOptions JsonOptions = new() @@ -23,119 +21,13 @@ public sealed class CommandClient : IDisposable }; private readonly HttpClient http; - private readonly SocketsHttpHandler handler; private readonly ILogger logger; - public CommandClient(AgentConfig config, ILogger logger) + public CommandClient(AgentHttpFactory httpFactory, ILogger logger) { this.logger = logger; - // The command channel long-polls: the server holds each claim open for a - // hold window and then, between windows, the pooled TLS connection sits - // 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; - } + // Pooling tuned for the long-poll lives in AgentHttpFactory (Command). + http = httpFactory.CreateClient(AgentHttpChannel.Command); } // Claim the next pending command for this hostname, or null when the server diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index e9d0e4f1..c38ad02a 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -73,14 +73,18 @@ public sealed class CommandExecutor private readonly ScheduledTaskPolicy scheduledTaskPolicy; // Held for update_app: the agent joins InstallPath to BaseUrl and downloads - // the pinned installer with its own collector credential (ApiKey), so the - // installer body never rides the command wire. + // the pinned installer with its own agent auth, so the installer body never + // rides the command wire. private readonly AgentConfig config; + // Builds the script/package download clients (pins + auth). + private readonly AgentHttpFactory httpFactory; + public CommandExecutor(ILogger logger, - AgentConfig config, TimeSpan? startTimeout = null) + AgentConfig config, AgentHttpFactory httpFactory, TimeSpan? startTimeout = null) { this.logger = logger; + this.httpFactory = httpFactory; this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); this.config = config ?? new AgentConfig(); 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 { 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), }; } @@ -1205,11 +1209,9 @@ public sealed class CommandExecutor } } - using var handler = new HttpClientHandler(); - using var client = new HttpClient(handler); - client.Timeout = TimeSpan.FromMinutes(5); + using var client = httpFactory.CreateClient( + AgentHttpChannel.Download, TimeSpan.FromMinutes(5)); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, scriptUrl); - httpRequest.Headers.Add("X-API-Key", config.ApiKey); using var response = await client.SendAsync( 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 { 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), }; } @@ -1906,11 +1908,9 @@ public sealed class CommandExecutor ProtectedFileSystem.EnsureDirectory(SystemStagingDirectoryName), $"shopdb-app-{packageKey}-{Guid.NewGuid():N}{ext}"); - using var handler = new HttpClientHandler(); - using var client = new HttpClient(handler); - client.Timeout = TimeSpan.FromMinutes(10); + using var client = httpFactory.CreateClient( + AgentHttpChannel.Download, TimeSpan.FromMinutes(10)); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, packageUrl); - httpRequest.Headers.Add("X-API-Key", config.ApiKey); using var response = await client.SendAsync( httpRequest, HttpCompletionOption.ResponseHeadersRead, token); diff --git a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs index 5327f8e8..b4165a90 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandWorker.cs @@ -43,11 +43,13 @@ public sealed class CommandWorker : BackgroundService private readonly CollectSignal? collectSignal; private readonly AgentConfig? config; private readonly string hostname; + private readonly EnrollmentService? enrollment; public CommandWorker(CommandClient client, CommandExecutor executor, ILogger logger, CollectSignal? collectSignal = null, - AgentConfig? config = null) + AgentConfig? config = null, EnrollmentService? enrollment = null) { + this.enrollment = enrollment; this.client = client; this.executor = executor; this.logger = logger; @@ -72,6 +74,18 @@ public sealed class CommandWorker : BackgroundService await SafeDelay(startupDelay, stoppingToken); } + if (enrollment is not null) + { + try + { + await enrollment.WaitForFirstAttemptAsync(TimeSpan.FromSeconds(20), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + while (!stoppingToken.IsCancellationRequested) { var pollStarted = System.Diagnostics.Stopwatch.StartNew(); diff --git a/agent/ShopDbEndpointAgent.Core/EnrollmentService.cs b/agent/ShopDbEndpointAgent.Core/EnrollmentService.cs new file mode 100644 index 00000000..3ec7f66e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/EnrollmentService.cs @@ -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 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 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 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; + } +} diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs index 5d45dbab..7f047367 100644 --- a/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -10,9 +9,9 @@ namespace ShopDbEndpointAgent.Core; // The server may publish site-wide alerts (maintenance windows, policy changes) // or endpoint-specific warnings (outdated agent version, failed telemetry push). // 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 -// collector key via X-API-Key, so the alert channel rides the same kill switch as -// telemetry and commands. +// and deserializes the JSON response into FleetAlert DTOs. Same auth as +// telemetry and commands (AgentHttpFactory). In 1.20 the service calls this and +// serves the tray over the local pipe (AgentPipeServer). public sealed class FleetAlertClient : IDisposable { private static readonly JsonSerializerOptions JsonOptions = new() @@ -21,106 +20,30 @@ public sealed class FleetAlertClient : IDisposable }; private readonly HttpClient http; - private readonly SocketsHttpHandler handler; private readonly ILogger logger; - public FleetAlertClient(AgentConfig config, ILogger logger) + public FleetAlertClient(AgentHttpFactory httpFactory, ILogger logger) { this.logger = logger; - handler = new SocketsHttpHandler - { - 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); + http = httpFactory.CreateClient(AgentHttpChannel.FleetAlert); } - // 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) + // Tray compatibility until tray 1.7.0 moves to AgentPipeClient: shared key + // only, no credential. Remove with the last tray that calls it. + public FleetAlertClient(AgentConfig config, ILogger logger) + : 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 X509Certificate2? FindClientCertificate(string thumbprint) + private sealed class SharedKeyOnlyProvider : ICredentialProvider { - 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 - { - return null; - } + public SharedKeyOnlyProvider(AgentConfig config) + => SharedKey = config.HasSharedKey() ? config.ApiKey : null; + public AgentCredential? Current => null; + public string? SharedKey { get; } + public event EventHandler? Changed { add { } remove { } } + public event EventHandler? EnrollmentNeeded { add { } remove { } } + public void ReportRejected(string code, string? sentCredential) { } } public async Task> FetchAlertsAsync( @@ -139,6 +62,9 @@ public sealed class FleetAlertClient : IDisposable var envelope = JsonSerializer.Deserialize( 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(); } diff --git a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj index 1a248310..a3b2e32a 100644 --- a/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj +++ b/agent/ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj @@ -15,14 +15,16 @@ + + - + diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index bf689192..04f4cb20 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -29,8 +29,12 @@ public sealed class TelemetryWorker : BackgroundService private readonly CollectSignal? collectSignal; private readonly UserContextSpool? userContextSpool; private readonly UserContextRefreshChannel? userContextRefresh; + private readonly EnrollmentService? enrollment; 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. // // 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 logger, IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null, UserContextSpool? userContextSpool = null, - UserContextRefreshChannel? userContextRefresh = null) + UserContextRefreshChannel? userContextRefresh = null, + EnrollmentService? enrollment = null) { + this.enrollment = enrollment; this.collector = collector; this.client = client; 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; while (!stoppingToken.IsCancellationRequested) { diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index 88cdb54d..7ccb132b 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -5,7 +5,7 @@ using ShopDbEndpointAgent.Core; 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 // 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 @@ -24,8 +24,7 @@ internal sealed class FleetAlertForm : Form private static readonly Color DefaultTypeColor = Color.Gray; private readonly FleetAlert alert; - private readonly FleetAlertClient? client; - private readonly string? hostname; + private readonly AgentPipeClient? client; private readonly ILogger? logger; private System.Windows.Forms.Timer? countdownTimer; private System.Windows.Forms.Timer? statusCheckTimer; @@ -40,11 +39,10 @@ internal sealed class FleetAlertForm : Form public event EventHandler? OnAcknowledgeClicked; 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.client = client; - this.hostname = hostname; this.logger = logger; SuspendLayout(); @@ -84,7 +82,7 @@ internal sealed class FleetAlertForm : Form System.Media.SystemSounds.Beep.Play(); - if (client != null && hostname != null) + if (client != null) { StartStatusCheck(); } @@ -109,7 +107,7 @@ internal sealed class FleetAlertForm : Form private void StatusCheckTimer_Tick(object? sender, EventArgs e) { - if (client == null || hostname == null) + if (client == null) return; var token = statusCheckCancellation.Token; @@ -120,9 +118,9 @@ internal sealed class FleetAlertForm : Form { try { - var alerts = await client.FetchAlertsAsync(hostname, token) + var alerts = await client.GetAlertsAsync(token) .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) { @@ -139,7 +137,7 @@ internal sealed class FleetAlertForm : Form } catch (Exception exception) { - // Keep the form open if status cannot be checked + // Agent unreachable or refused: keep the form open logger?.LogDebug(exception, "Status check for fleet alert {NotificationId} failed.", alert.NotificationId); } diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs index 5b3aa4c1..1ead5d25 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -3,19 +3,21 @@ using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; -// Polls the server for fleet-wide alerts every 60 seconds and displays new -// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in -// FleetAlertStore and posted back to the server so they do not reappear in -// future polls. Runs for the tray's lifetime on a dedicated background thread. +// Asks the local agent for fleet-wide alerts every 60 seconds and displays new +// alerts to the operator via FleetAlertForm. The agent holds the credential and +// talks to the server; the tray only speaks the pipe. An alert is recorded in +// 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 { private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60); 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 string hostname; - private readonly int assetId; private readonly ILogger logger; private readonly SynchronizationContext? uiContext; private readonly CancellationTokenSource cancellation; @@ -24,18 +26,18 @@ internal sealed class FleetAlertWorker : IDisposable // Set and cleared on the UI thread only. private FleetAlertForm? openForm; + // Poller thread only. + private DateTime lastUnreachableLogUtc = DateTime.MinValue; + private int unreachableCount; + public FleetAlertWorker( - FleetAlertClient client, + AgentPipeClient client, FleetAlertStore store, - string hostname, - int assetId, ILogger logger, SynchronizationContext? uiContext = null) { this.client = client ?? throw new ArgumentNullException(nameof(client)); 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.uiContext = uiContext ?? SynchronizationContext.Current; cancellation = new CancellationTokenSource(); @@ -71,6 +73,10 @@ internal sealed class FleetAlertWorker : IDisposable { return; } + catch (AgentPipeException exception) + { + LogUnreachable(exception); + } catch (Exception exception) { 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) { - 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) { logger.LogWarning( "Skipped {Count} malformed fleet alert(s): {Details}", parsed.Skipped.Count, string.Join("; ", parsed.Skipped)); } - if (alerts is null || alerts.Count == 0) + if (alerts.Count == 0) { logger.LogDebug("No active fleet alerts."); return; @@ -129,7 +154,7 @@ internal sealed class FleetAlertWorker : IDisposable if (token.IsCancellationRequested) 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); openForm = form; try @@ -168,28 +193,45 @@ internal sealed class FleetAlertWorker : IDisposable private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token) { logger.LogInformation( - "Fleet alert {NotificationId} acknowledged; queuing server post.", alert.NotificationId); - - store.MarkAcknowledged(alert.NotificationId); + "Fleet alert {NotificationId} acknowledged; sending to the agent.", 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 () => { try { - await client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token); - logger.LogInformation("Fleet alert {NotificationId} posted to server.", alert.NotificationId); + var response = await client.AckAlertAsync(alert.NotificationId, token); + 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) { 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); } }); } - // Closes a displayed alert without acknowledging it. Call on the UI thread - // before disposing the client, so the form's status check stops using it. + // Closes a displayed alert without acknowledging it, so the form's status + // check stops polling the agent on shutdown. public void CloseOpenAlert() { var form = openForm; diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 6c48df02..22ae57b1 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -106,7 +106,18 @@ %LOCALAPPDATA%\ShopDB\FleetAlerts (the operator could not write the ProgramData copy, so acks were lost every logon), merging the old file once. Shutdown no longer blocks on the poller's sleeps. --> - 1.6.11 + + 1.7.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index f0cadab0..82dc0cbb 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -11,11 +11,11 @@ namespace ShopDbEndpointAgent.Tray; // and headless-friendly: no main window, the icon is the whole UI surface until // the operator opens the ticket dialog. // -// "Send Status now" reuses the exact production collection path by shelling out -// to the installed shopdbagent.exe with --once. That keeps the collector key and -// all collection logic in the service binary: the tray never holds a secret and -// never duplicates wiring, it just asks the agent to report on demand so the -// ShopDB monitor page reflects the box immediately instead of at next cadence. +// "Send Status now", "Report an issue" and fleet alerts all go through the +// SYSTEM agent's local pipe (AgentPipeClient). The agent holds the credential +// and all collection logic: the tray never reads a key or config and never +// duplicates wiring, it just asks the agent to act on demand so the ShopDB +// monitor page reflects the box immediately instead of at next cadence. internal sealed class TrayContext : ApplicationContext { // 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 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 // 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 @@ -82,11 +90,10 @@ internal sealed class TrayContext : ApplicationContext // guard and launch two probes at the same spool file. private readonly SynchronizationContext? uiContext; - // Fleet alerts are pulled from the server and displayed to the operator as - // toast notifications in the top-right corner. The worker polls every 60 - // seconds, filters out already-acknowledged alerts via the store, and posts - // acknowledgements back to the server when the operator dismisses the form. - private readonly FleetAlertClient? fleetAlertClient; + // Fleet alerts are fetched through the agent pipe and displayed to the + // operator as toast notifications in the top-right corner. The worker polls + // every 60 seconds, filters out already-acknowledged alerts via the store, + // and sends acknowledgements to the agent when the operator dismisses the form. private readonly FleetAlertStore? fleetAlertStore; private readonly FleetAlertWorker? fleetAlertWorker; @@ -175,27 +182,16 @@ internal sealed class TrayContext : ApplicationContext UserContextInterval); } - // Fleet alert worker. Best-effort: if the config cannot be loaded or the - // worker fails to start, the tray still runs and the other features work. - // The operator just does not receive fleet-wide alerts. Loading the config - // 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. + // Fleet alert worker. Best-effort: if the worker fails to start, the tray + // still runs and the other features work. An agent that is down is not a + // startup failure; the worker retries the pipe every cycle. 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( logProvider.CreateLogger(nameof(FleetAlertStore))); fleetAlertWorker = new FleetAlertWorker( - fleetAlertClient, + pipeClient, fleetAlertStore, - hostname, - assetId, logProvider.CreateLogger(nameof(FleetAlertWorker)), uiContext); fleetAlertWorker.Start(); @@ -390,13 +386,7 @@ internal sealed class TrayContext : ApplicationContext includeScreenshot = dialog.IncludeScreenshot; } - var agentExe = ResolveAgentExe(); - if (agentExe is null) - { - ShowBalloon("Report an issue", "Could not find " + AgentExeName + - ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); - return; - } + var token = shutdownCancellation.Token; // Capture AFTER the dialog closes so the report form is never in the // 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...", ToolTipIcon.Info); - int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\""); - if (exitCode == 0) + // The agent checks the path is inside this user's own profile. + var response = await pipeClient.SendReportAsync(payloadPath, token); + if (response.Ok) ShowBalloon("Report an issue", "Your report was sent to ShopDB.", ToolTipIcon.Info); + else if (response.Data is null) + ShowBalloon("Report an issue", AgentUnreachableText, ToolTipIcon.Error); else ShowBalloon("Report an issue", - "Report send failed (exit " + exitCode + + "Report send failed (" + response.Error + "). See the agent log under ProgramData\\ShopDB.", ToolTipIcon.Warning); } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Tray is exiting. + } catch (Exception error) { 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 - // fresh report without waiting for the next cadence tick. The exit code is - // the agent's own contract: 0 = reported OK, non-zero = failed (bad key, - // host removed, network) and the detail is in the rolling agent log. + // Asks the agent over the pipe to run telemetry once so the operator can + // push a fresh report without waiting for the next cadence tick. The agent + // answers ok or an error; the detail is in the rolling agent log. private async void OnSendStatus(object? sender, EventArgs e) { if (sending) return; - var agentExe = ResolveAgentExe(); - if (agentExe is null) - { - ShowBalloon("Send Status", "Could not find " + AgentExeName + - ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); - return; - } - + var token = shutdownCancellation.Token; sending = true; trayIcon.Text = "ShopDB Endpoint Agent (sending status...)"; 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 // 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's answer and the operator would click again and see the same // stale card. @@ -510,25 +499,33 @@ internal sealed class TrayContext : ApplicationContext // this button is for. await CollectUserContextAsync(); - int exitCode = await RunAgentAsync(agentExe, "--once"); - if (exitCode == 0) + var response = await pipeClient.SendStatusAsync(token); + if (response.Ok) ShowBalloon("Send Status", "Status sent to ShopDB.", ToolTipIcon.Info); + else if (response.Data is null) + ShowBalloon("Send Status", AgentUnreachableText, ToolTipIcon.Error); else ShowBalloon("Send Status", - "Status send failed (exit " + exitCode + + "Status send failed (" + response.Error + "). See the agent log under ProgramData\\ShopDB.", ToolTipIcon.Warning); } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // Tray is exiting. + return; + } catch (Exception error) { - ShowBalloon("Send Status", "Could not run the agent: " + + ShowBalloon("Send Status", "Could not reach the agent: " + error.Message, ToolTipIcon.Error); } finally { 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(); } - // 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>(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) { if (disposing) @@ -725,8 +692,9 @@ internal sealed class TrayContext : ApplicationContext refreshCancellation.Cancel(); refreshCancellation.Dispose(); + shutdownCancellation.Cancel(); fleetAlertWorker?.Dispose(); - fleetAlertClient?.Dispose(); + shutdownCancellation.Dispose(); trayIcon.Dispose(); brandIcon.Dispose(); diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index 7ed76530..afbd2032 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -11,12 +11,13 @@ using ShopDbEndpointAgent.Core; // --console run interactively in the foreground for debugging // --once collect and report exactly once, then exit (smoke test / cron) // --report upload one operator issue report (JSON sidecar written by -// the tray), then exit. Keeps the collector key in the -// service so the tray never holds a secret. +// the tray), then exit. +// 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 // (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 // 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 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 // CyberArk/EPM may gate) is fully diagnosable before the host starts. using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory()); @@ -136,15 +171,24 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath) ? FileLoggerProvider.DefaultDirectory() : 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( - "Config incomplete: provision CollectorKey and BaseUrl in " + - "HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " + + "Config incomplete: provision BaseUrl in " + + "HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl in appsettings.json for dev). " + "See the registry lines above to tell 'missing' from 'access denied'."); 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(); if (baseUrlProblem is not null) { @@ -171,53 +215,6 @@ catch (Exception error) static async Task RunHostAsync(AgentConfig config, string agentVersion, 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(); - 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(); if (!forceConsole) @@ -226,16 +223,43 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion, ConfigureLogging(builder.Logging, logDirectory); 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(), + sp.GetRequiredService(), + sp.GetRequiredService(), + agentVersion, + sp.GetRequiredService>())); + builder.Services.AddSingleton(sp => new AgentPipeServer( + config, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + agentVersion, + sp.GetRequiredService>())); + + builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); - // Background monitor for certificate expiry (critical for long-running agents) - // Checks every 24 hours, triggers renewal if < 30 days remaining - // Without this, agents running > 30 days would have expired certs - builder.Services.AddSingleton(); - builder.Services.AddHostedService(sp => sp.GetRequiredService()); + var host = builder.Build(); - 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(); + var collectSignal = host.Services.GetRequiredService(); + credentials.Changed += (_, _) => + { + if (credentials.Current is not null) + collectSignal.Request(); + }; + + await host.RunAsync(); return 0; } @@ -259,8 +283,8 @@ static async Task RunOnceAsync(AgentConfig config, string agentVersion, } // 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 -// collector key stays in the service binary. The exit code is the tray's +// then deletes the sidecar. SYSTEM only; a user-context --report goes through +// 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). static async Task RunReportAsync(AgentConfig config, string payloadPath, string logDirectory, AgentFaultMonitor faultMonitor) @@ -371,8 +395,28 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config sp.GetRequiredService(), sp.GetRequiredService())); + // 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().CreateLogger("Credentials"))); + services.AddSingleton(sp => new AgentCredentialProvider( + config, + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger("Credentials"))); + services.AddSingleton(sp => + sp.GetRequiredService()); + services.AddSingleton(sp => new AgentHttpFactory( + config, + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger("Http"))); + services.AddSingleton(sp => new CollectorClient( - config, sp.GetRequiredService>())); + sp.GetRequiredService(), + sp.GetRequiredService>())); + + services.AddSingleton(sp => new FleetAlertClient( + sp.GetRequiredService(), + sp.GetRequiredService().CreateLogger(nameof(FleetAlertClient)))); services.AddSingleton(sp => new TelemetryWorker( sp.GetRequiredService(), @@ -383,24 +427,28 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config sp.GetService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetService())); // Command channel: outbound long-poll for allowlisted WebUI commands, run // through the fail-closed executor. Hosted alongside telemetry but on its // own loop so a held-open poll never delays a report. services.AddSingleton(sp => new CommandClient( - config, sp.GetRequiredService>())); + sp.GetRequiredService(), + sp.GetRequiredService>())); services.AddSingleton(sp => new CommandExecutor( sp.GetRequiredService>(), - config)); + config, + sp.GetRequiredService())); services.AddSingleton(sp => new CommandWorker( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService(), - config)); + config, + sp.GetService())); } static void ConfigureLogging(ILoggingBuilder logging, string logDirectory) diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 40c8ab93..b173074e 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -265,7 +265,27 @@ material and reparse points, and rechecks the handle's final path. Certificate renewal at start is bounded to 15s and exits non-zero so SCM recovery restarts the service. --> -1.19.0 + +1.20.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json index 78479ed8..4c640d63 100644 --- a/agent/ShopDbEndpointAgent/appsettings.json +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -1,6 +1,7 @@ { "baseurl": "", "apikey": "REPLACE_WITH_COLLECTOR_KEY", + "serverpins": [], "cadenceseconds": 900, "jitterseconds": 60, "startupjitterseconds": 30,