Authenticate the agent with its own credential, pin the server, and take secrets out of the tray
Agent 1.20.0, tray 1.7.0. Ship together. Agent: - Every HTTP client, including script and package downloads, is built by one AgentHttpFactory. Server pins (SPKI SHA-256, current and next) come from HKLM ServerPins or appsettings serverpins; with a pin set only a matching key is trusted, otherwise the normal chain check applies. - EnrollmentService enrolls once using the shared key the PC already has, stores the credential DPAPI-protected in a SYSTEM-only directory, and swaps to it without a restart. Pending requests retry every 15 minutes; an old server without the enroll route leaves the agent on the shared key. A revoked credential is deleted and the PC re-enrolls. - A credential is only ever sent to the server that issued it. - Telemetry posts to /api/endpoints/collect when using a credential. - A named pipe lets the interactive user ask the agent to send status, send a report, list alerts and acknowledge one. The report sidecar and screenshot must sit in the caller's own profile and not be a link. - --once and --report run as a user become pipe clients. - appsettings.json is restricted to SYSTEM and Administrators once the pipe is up. - Removed client certificate renewal and expiry monitoring. Tray: - Holds no key and reads no agent configuration. Alerts, acknowledgements, Send Status and Report an issue all go through the agent's pipe. - An acknowledgement is remembered only when the agent confirms it. - When the agent is not running, the menu says so and alert polling retries quietly.
This commit is contained in:
@@ -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<string> 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<string> 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
|
||||
|
||||
269
agent/ShopDbEndpointAgent.Core/AgentCredentials.cs
Normal file
269
agent/ShopDbEndpointAgent.Core/AgentCredentials.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// This PC's own endpoint credential, issued once by POST /api/endpoints/enroll.
|
||||
// Readable only by SYSTEM and Administrators (DPAPI LocalMachine inside a
|
||||
// protected directory). The JSON key names are a contract: SYSTEM PowerShell
|
||||
// (upgrade-shopdbagent.ps1, the reporters) reads the same file with
|
||||
// [System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine').
|
||||
public sealed class AgentCredential
|
||||
{
|
||||
[JsonPropertyName("credential")] public string Credential { get; set; } = "";
|
||||
[JsonPropertyName("assetid")] public int AssetId { get; set; }
|
||||
[JsonPropertyName("issuedat")] public string IssuedAt { get; set; } = "";
|
||||
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
|
||||
|
||||
// First 12 chars, the same prefix the server shows. Never log more.
|
||||
[JsonIgnore]
|
||||
public string Prefix => Credential.Length <= 12 ? Credential : Credential[..12];
|
||||
}
|
||||
|
||||
// Why the server refused an agent request. Parsed from the error envelope.
|
||||
public static class AgentAuthErrorCodes
|
||||
{
|
||||
public const string CredentialInvalid = "CREDENTIAL_INVALID";
|
||||
public const string SharedKeyRefused = "SHARED_KEY_REFUSED";
|
||||
public const string AssetMismatch = "ASSET_MISMATCH";
|
||||
}
|
||||
|
||||
// Current auth material for outbound requests. Swaps at runtime: enrollment
|
||||
// sets a credential, a CREDENTIAL_INVALID clears it, neither needs a restart.
|
||||
public interface ICredentialProvider
|
||||
{
|
||||
// Null = not enrolled (or the stored credential belongs to another server).
|
||||
AgentCredential? Current { get; }
|
||||
|
||||
// Shared fleet collector key, null when not provisioned.
|
||||
string? SharedKey { get; }
|
||||
|
||||
// Raised after Current changes (set or cleared).
|
||||
event EventHandler? Changed;
|
||||
|
||||
// Raised when the enrollment service should run now.
|
||||
event EventHandler? EnrollmentNeeded;
|
||||
|
||||
// Called by the auth handler for every 401/403 carrying an auth code.
|
||||
// sentCredential is the credential the failed request carried, or null
|
||||
// when it went out on the shared key.
|
||||
void ReportRejected(string code, string? sentCredential);
|
||||
}
|
||||
|
||||
public sealed class AgentCredentialProvider : ICredentialProvider
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly CredentialStore? store;
|
||||
private readonly ILogger logger;
|
||||
private AgentCredential? current;
|
||||
|
||||
public AgentCredentialProvider(AgentConfig config, CredentialStore? store, ILogger logger)
|
||||
{
|
||||
this.store = store;
|
||||
this.logger = logger;
|
||||
SharedKey = config.HasSharedKey() ? config.ApiKey : null;
|
||||
|
||||
var loaded = store?.TryLoad();
|
||||
if (loaded is not null && !SameServer(loaded.BaseUrl, config.BaseUrl))
|
||||
{
|
||||
// Never send a credential to a server that did not issue it. Kept on
|
||||
// disk so pointing BaseUrl back restores it; a new enroll overwrites.
|
||||
logger.LogWarning(
|
||||
"Stored credential {Prefix} was issued by {Issuer}, not {BaseUrl}; not using it.",
|
||||
loaded.Prefix, loaded.BaseUrl, config.BaseUrl);
|
||||
loaded = null;
|
||||
}
|
||||
current = loaded;
|
||||
if (current is not null)
|
||||
logger.LogInformation("Using endpoint credential {Prefix} (asset {AssetId}).",
|
||||
current.Prefix, current.AssetId);
|
||||
}
|
||||
|
||||
public AgentCredential? Current
|
||||
{
|
||||
get { lock (gate) return current; }
|
||||
}
|
||||
|
||||
public string? SharedKey { get; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
public event EventHandler? EnrollmentNeeded;
|
||||
|
||||
// Persist then swap. Throws when the store cannot be written: a credential
|
||||
// held only in memory is lost at restart and the server will not reissue.
|
||||
public void Set(AgentCredential credential)
|
||||
{
|
||||
store?.Save(credential);
|
||||
lock (gate) current = credential;
|
||||
logger.LogInformation("Endpoint credential {Prefix} stored (asset {AssetId}).",
|
||||
credential.Prefix, credential.AssetId);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void ReportRejected(string code, string? sentCredential)
|
||||
{
|
||||
if (code == AgentAuthErrorCodes.CredentialInvalid && sentCredential is not null)
|
||||
{
|
||||
bool cleared = false;
|
||||
lock (gate)
|
||||
{
|
||||
// Only the credential that was refused. A late response for an
|
||||
// old credential must not wipe a freshly issued one.
|
||||
if (current is not null && current.Credential == sentCredential)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Server refused credential {Prefix} (revoked or unknown); deleting it and re-enrolling.",
|
||||
current.Prefix);
|
||||
current = null;
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
if (cleared)
|
||||
{
|
||||
try
|
||||
{
|
||||
store?.Delete();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Could not delete the refused credential file.");
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
EnrollmentNeeded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
else if (code == AgentAuthErrorCodes.SharedKeyRefused && sentCredential is null && Current is null)
|
||||
{
|
||||
logger.LogWarning("Server refused the shared key for this PC; requesting enrollment.");
|
||||
EnrollmentNeeded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else if (code == AgentAuthErrorCodes.AssetMismatch)
|
||||
{
|
||||
logger.LogError(
|
||||
"Server reported ASSET_MISMATCH: this PC's credential belongs to a different asset than the request named.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameServer(string issued, string configured)
|
||||
=> string.Equals(Normalize(issued), Normalize(configured), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string Normalize(string url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
// credential.bin and enroll.json under %ProgramData%\ShopDB\EndpointAgent\credential.
|
||||
public sealed class CredentialStore
|
||||
{
|
||||
public const string DirectoryName = "credential";
|
||||
public const string CredentialFileName = "credential.bin";
|
||||
public const string EnrollFileName = "enroll.json";
|
||||
|
||||
private const int MaxFileBytes = 64 * 1024;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly ILogger logger;
|
||||
|
||||
public CredentialStore(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private static string StoreDirectory => ProtectedFileSystem.EnsureDirectory(DirectoryName);
|
||||
|
||||
public AgentCredential? TryLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(StoreDirectory, CredentialFileName);
|
||||
if (ProtectedFileSystem.TryGetAttributes(path) is null)
|
||||
return null;
|
||||
|
||||
var blob = ReadAllBytesNoFollow(path);
|
||||
var json = ProtectedData.Unprotect(blob, null, DataProtectionScope.LocalMachine);
|
||||
var credential = JsonSerializer.Deserialize<AgentCredential>(json, JsonOptions);
|
||||
if (credential is null || !credential.Credential.StartsWith("sdbe_", StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogWarning("Stored credential file is not a usable credential; ignoring it.");
|
||||
return null;
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Could not read the stored endpoint credential; running unenrolled.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(AgentCredential credential)
|
||||
{
|
||||
var dir = StoreDirectory;
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(credential);
|
||||
var blob = ProtectedData.Protect(json, null, DataProtectionScope.LocalMachine);
|
||||
var target = Path.Combine(dir, CredentialFileName);
|
||||
var temp = Path.Combine(dir, CredentialFileName + ".new");
|
||||
using (var file = ProtectedFileSystem.CreateFile(temp))
|
||||
{
|
||||
file.Write(blob);
|
||||
file.Flush(flushToDisk: true);
|
||||
}
|
||||
// Moved file keeps its protected DACL.
|
||||
File.Move(temp, target, overwrite: true);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
=> ProtectedFileSystem.DeleteFileNoFollow(Path.Combine(StoreDirectory, CredentialFileName));
|
||||
|
||||
// The enroll nonce is generated once and kept for the life of the PC, so
|
||||
// an approved request can only be claimed by the agent that made it.
|
||||
public string GetOrCreateEnrollNonce()
|
||||
{
|
||||
var path = Path.Combine(StoreDirectory, EnrollFileName);
|
||||
if (ProtectedFileSystem.TryGetAttributes(path) is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(ReadAllBytesNoFollow(path));
|
||||
if (doc.RootElement.TryGetProperty("enrollnonce", out var value)
|
||||
&& value.GetString() is string nonce && nonce.Length >= 32)
|
||||
return nonce;
|
||||
logger.LogWarning("enroll.json has no usable nonce; generating a new one.");
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogWarning(error, "enroll.json unreadable; generating a new nonce.");
|
||||
}
|
||||
}
|
||||
|
||||
var fresh = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
enrollnonce = fresh,
|
||||
createdat = DateTime.UtcNow.ToString("o"),
|
||||
});
|
||||
using (var file = ProtectedFileSystem.CreateFile(path))
|
||||
{
|
||||
file.Write(body);
|
||||
file.Flush(flushToDisk: true);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
internal static string Base64Url(byte[] bytes)
|
||||
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static byte[] ReadAllBytesNoFollow(string path)
|
||||
{
|
||||
using var stream = ProtectedFileSystem.OpenReadNoFollow(path);
|
||||
if (stream.Length > MaxFileBytes)
|
||||
throw new InvalidDataException($"{path} is larger than expected");
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
299
agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs
Normal file
299
agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// What an HttpClient is for. Decides pooling, timeout and auth.
|
||||
public enum AgentHttpChannel
|
||||
{
|
||||
Collector,
|
||||
Command,
|
||||
FleetAlert,
|
||||
Download,
|
||||
Enroll,
|
||||
}
|
||||
|
||||
// The ONLY place the agent builds an HttpClient or a handler. Every channel gets
|
||||
// the same server certificate check (ServerPinSet) and the same per-request auth
|
||||
// (AgentAuthHandler), so a new channel cannot forget either.
|
||||
public sealed class AgentHttpFactory
|
||||
{
|
||||
private readonly AgentConfig config;
|
||||
private readonly ICredentialProvider credentials;
|
||||
private readonly ILogger logger;
|
||||
private readonly ServerPinSet pins;
|
||||
|
||||
public AgentHttpFactory(AgentConfig config, ICredentialProvider credentials, ILogger logger)
|
||||
{
|
||||
this.config = config;
|
||||
this.credentials = credentials;
|
||||
this.logger = logger;
|
||||
pins = ServerPinSet.Load(config, logger);
|
||||
}
|
||||
|
||||
public ICredentialProvider Credentials => credentials;
|
||||
|
||||
// Caller owns and disposes the client (it disposes the handler chain).
|
||||
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
|
||||
{
|
||||
var sockets = new SocketsHttpHandler
|
||||
{
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, cert, _, errors)
|
||||
=> pins.Validate(cert, errors, channel),
|
||||
},
|
||||
};
|
||||
|
||||
switch (channel)
|
||||
{
|
||||
case AgentHttpChannel.Command:
|
||||
// Long-poll: retire pooled TLS connections faster than the
|
||||
// server keep-alive, or a reused dead socket yields "unexpected
|
||||
// EOF or 0 bytes from the transport stream" (1.6.2).
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
|
||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
||||
break;
|
||||
case AgentHttpChannel.FleetAlert:
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
|
||||
sockets.PooledConnectionLifetime = TimeSpan.FromMinutes(2);
|
||||
break;
|
||||
}
|
||||
|
||||
var auth = new AgentAuthHandler(credentials, logger,
|
||||
sharedKeyOnly: channel == AgentHttpChannel.Enroll)
|
||||
{
|
||||
InnerHandler = sockets,
|
||||
};
|
||||
|
||||
return new HttpClient(auth, disposeHandler: true)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = timeout ?? DefaultTimeout(channel),
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan DefaultTimeout(AgentHttpChannel channel) => channel switch
|
||||
{
|
||||
// Claim is held open server-side (~25s); clear that plus slack.
|
||||
AgentHttpChannel.Command => TimeSpan.FromSeconds(60),
|
||||
AgentHttpChannel.Download => TimeSpan.FromMinutes(10),
|
||||
_ => TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
// Adds exactly one auth header per request, read at send time so a credential
|
||||
// issued or revoked mid-run takes effect on the next request. Watches 401/403
|
||||
// replies for the auth error codes and reports them to the provider.
|
||||
public sealed class AgentAuthHandler : DelegatingHandler
|
||||
{
|
||||
public const string CredentialHeader = "X-Endpoint-Credential";
|
||||
public const string SharedKeyHeader = "X-API-Key";
|
||||
|
||||
private readonly ICredentialProvider credentials;
|
||||
private readonly ILogger logger;
|
||||
private readonly bool sharedKeyOnly;
|
||||
|
||||
public AgentAuthHandler(ICredentialProvider credentials, ILogger logger, bool sharedKeyOnly = false)
|
||||
{
|
||||
this.credentials = credentials;
|
||||
this.logger = logger;
|
||||
this.sharedKeyOnly = sharedKeyOnly;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.Headers.Remove(CredentialHeader);
|
||||
request.Headers.Remove(SharedKeyHeader);
|
||||
|
||||
var credential = sharedKeyOnly ? null : credentials.Current?.Credential;
|
||||
if (credential is not null)
|
||||
request.Headers.TryAddWithoutValidation(CredentialHeader, credential);
|
||||
else if (credentials.SharedKey is string sharedKey)
|
||||
request.Headers.TryAddWithoutValidation(SharedKeyHeader, sharedKey);
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Buffered so the caller can still read the body.
|
||||
await response.Content.LoadIntoBufferAsync();
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var code = AgentServerError.TryParse(body)?.Code;
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
credentials.ReportRejected(code, credential);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogDebug(error, "Could not read an auth failure body");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// The server's error envelope: {"status":"error","data":{"error":{"code","message"}}}.
|
||||
public sealed record AgentServerError(string Code, string Message)
|
||||
{
|
||||
public static AgentServerError? TryParse(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
root = data;
|
||||
if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
var code = error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String
|
||||
? c.GetString() ?? "" : "";
|
||||
var message = error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String
|
||||
? m.GetString() ?? "" : "";
|
||||
return new AgentServerError(code, message);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo,
|
||||
// merged from HKLM\SOFTWARE\GE\ShopDB ServerPins (both views) and appsettings
|
||||
// serverpins; the legacy SHA-1 servercertthumbprint still counts. With any pin
|
||||
// configured only a matching leaf is accepted and chain/name errors are ignored
|
||||
// (self-signed servers on the air gap). Without pins: SslPolicyErrors.None.
|
||||
public sealed class ServerPinSet
|
||||
{
|
||||
public const string RegistryValue = "ServerPins";
|
||||
|
||||
private readonly HashSet<string> spkiPins;
|
||||
private readonly string? legacyThumbprint;
|
||||
private readonly ILogger logger;
|
||||
|
||||
private ServerPinSet(HashSet<string> spkiPins, string? legacyThumbprint, ILogger logger)
|
||||
{
|
||||
this.spkiPins = spkiPins;
|
||||
this.legacyThumbprint = legacyThumbprint;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public bool IsPinned => spkiPins.Count > 0 || legacyThumbprint is not null;
|
||||
|
||||
public static ServerPinSet Load(AgentConfig config, ILogger logger)
|
||||
{
|
||||
var pins = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var pin in config.ServerPins ?? new List<string>())
|
||||
AddPin(pins, pin, "appsettings.json", logger);
|
||||
foreach (var pin in ReadRegistryPins(logger))
|
||||
AddPin(pins, pin, "registry", logger);
|
||||
|
||||
string? thumbprint = null;
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
|
||||
|
||||
logger.LogInformation(
|
||||
"Server certificate trust: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||
pins.Count, thumbprint is null ? "" : " + legacy thumbprint",
|
||||
pins.Count > 0 || thumbprint is not null ? "pinned" : "Windows chain validation");
|
||||
return new ServerPinSet(pins, thumbprint, logger);
|
||||
}
|
||||
|
||||
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate ({Channel})", channel);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsPinned)
|
||||
{
|
||||
if (errors == SslPolicyErrors.None)
|
||||
return true;
|
||||
logger.LogWarning(
|
||||
"Server certificate rejected on {Channel} channel: {Errors} ({Subject})",
|
||||
channel, errors, certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
var owned = certificate as X509Certificate2;
|
||||
var leaf = owned ?? new X509Certificate2(certificate);
|
||||
try
|
||||
{
|
||||
var spki = SpkiSha256(leaf);
|
||||
if (spkiPins.Contains(spki))
|
||||
return true;
|
||||
if (legacyThumbprint is not null
|
||||
&& string.Equals(leaf.GetCertHashString(), legacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
logger.LogError(
|
||||
"Server certificate does not match any pin on {Channel} channel: spki {Spki} ({Subject})",
|
||||
channel, spki, leaf.Subject);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (owned is null)
|
||||
leaf.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static string SpkiSha256(X509Certificate2 certificate)
|
||||
=> Convert.ToHexString(SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo()))
|
||||
.ToLowerInvariant();
|
||||
|
||||
private static void AddPin(HashSet<string> pins, string? raw, string source, ILogger logger)
|
||||
{
|
||||
var pin = (raw ?? "").Replace(":", "").Replace(" ", "").Trim().ToLowerInvariant();
|
||||
if (pin.Length == 0)
|
||||
return;
|
||||
if (pin.Length != 64 || !pin.All(Uri.IsHexDigit))
|
||||
{
|
||||
logger.LogWarning("Ignoring malformed server pin from {Source}: not 64 hex chars", source);
|
||||
return;
|
||||
}
|
||||
pins.Add(pin);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadRegistryPins(ILogger logger)
|
||||
{
|
||||
var found = new List<string>();
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
|
||||
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
|
||||
switch (key?.GetValue(RegistryValue))
|
||||
{
|
||||
case string[] many:
|
||||
found.AddRange(many);
|
||||
break;
|
||||
case string one:
|
||||
found.AddRange(one.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
180
agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs
Normal file
180
agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Wire constants for the local agent pipe. One UTF-8 JSON object per line:
|
||||
// request, response, close.
|
||||
public static class AgentPipeProtocol
|
||||
{
|
||||
public const string PipeName = "ShopDB.EndpointAgent.v1";
|
||||
public const int MaxRequestBytes = 64 * 1024;
|
||||
public const int MaxResponseBytes = 4 * 1024 * 1024;
|
||||
public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(3);
|
||||
public static readonly TimeSpan RequestReadTimeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
// Ok/Error as the agent answered. Data is the whole response object (ping
|
||||
// fields such as agentversion/enrolled/assetid live there). Data is null when
|
||||
// the agent could not be reached or answered garbage; Error then says why.
|
||||
public sealed record PipeResponse(bool Ok, string? Error, JsonElement? Data);
|
||||
|
||||
public sealed class AgentPipeException : Exception
|
||||
{
|
||||
public AgentPipeException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AgentPipeException(string message, Exception inner) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Client for the SYSTEM agent's local pipe. Used by the tray (which holds no
|
||||
// secret) and by shopdbagent.exe --once/--report when not running as SYSTEM.
|
||||
// Only GetAlertsAsync throws; the others report failure through PipeResponse.
|
||||
public sealed class AgentPipeClient
|
||||
{
|
||||
// Send Status waits on the user-context refresh plus the upload retries.
|
||||
private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(3);
|
||||
private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(45);
|
||||
|
||||
private static readonly JsonSerializerOptions AlertOptions = new()
|
||||
{
|
||||
Converters = { new TolerantFleetAlertListConverter() },
|
||||
};
|
||||
|
||||
public Task<PipeResponse> PingAsync(CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "ping" }, ShortOperationTimeout, cancellationToken);
|
||||
|
||||
public Task<PipeResponse> SendStatusAsync(CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "sendstatus" }, LongOperationTimeout, cancellationToken);
|
||||
|
||||
public Task<PipeResponse> SendReportAsync(string spoolPath, CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "sendreport", spoolpath = spoolPath }, LongOperationTimeout,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<FleetAlert>> GetAlertsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await SendAsync(new { op = "alerts" }, ShortOperationTimeout, cancellationToken);
|
||||
if (!response.Ok)
|
||||
throw new AgentPipeException(response.Error ?? "agent refused the alerts request");
|
||||
|
||||
if (response.Data is not JsonElement data
|
||||
|| !data.TryGetProperty("alerts", out var alerts)
|
||||
|| alerts.ValueKind != JsonValueKind.Array)
|
||||
throw new AgentPipeException("agent response has no alerts array");
|
||||
|
||||
try
|
||||
{
|
||||
return alerts.Deserialize<List<FleetAlert>>(AlertOptions) ?? new List<FleetAlert>();
|
||||
}
|
||||
catch (JsonException error)
|
||||
{
|
||||
throw new AgentPipeException("agent alerts could not be parsed", error);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PipeResponse> AckAlertAsync(int notificationId, CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "ackalert", notificationid = notificationId },
|
||||
ShortOperationTimeout, cancellationToken);
|
||||
|
||||
private static async Task<PipeResponse> SendSafeAsync(
|
||||
object request, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SendAsync(request, timeout, cancellationToken);
|
||||
}
|
||||
catch (AgentPipeException error)
|
||||
{
|
||||
return new PipeResponse(false, error.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Throws AgentPipeException on any transport or framing failure, and
|
||||
// OperationCanceledException only when the caller cancelled.
|
||||
private static async Task<PipeResponse> SendAsync(
|
||||
object request, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
var token = timeoutSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
// Identification: the agent may learn who we are, never act as us.
|
||||
await using var pipe = new NamedPipeClientStream(".", AgentPipeProtocol.PipeName,
|
||||
PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification);
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.ConnectAsync((int)AgentPipeProtocol.ConnectTimeout.TotalMilliseconds, token);
|
||||
}
|
||||
catch (TimeoutException error)
|
||||
{
|
||||
throw new AgentPipeException("agent not reachable (is the ShopDB Endpoint Agent service running?)", error);
|
||||
}
|
||||
|
||||
var line = JsonSerializer.SerializeToUtf8Bytes(request);
|
||||
await pipe.WriteAsync(line, token);
|
||||
await pipe.WriteAsync(new[] { (byte)'\n' }, token);
|
||||
await pipe.FlushAsync(token);
|
||||
|
||||
var body = await ReadLineAsync(pipe, AgentPipeProtocol.MaxResponseBytes, token)
|
||||
?? throw new AgentPipeException("agent closed the pipe without answering");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
throw new AgentPipeException("agent response is not a JSON object");
|
||||
|
||||
var ok = root.TryGetProperty("ok", out var okValue) && okValue.ValueKind == JsonValueKind.True;
|
||||
var errorText = root.TryGetProperty("error", out var errorValue)
|
||||
&& errorValue.ValueKind == JsonValueKind.String
|
||||
? errorValue.GetString() : null;
|
||||
return new PipeResponse(ok, ok ? null : errorText ?? "agent reported failure", root.Clone());
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException error)
|
||||
{
|
||||
throw new AgentPipeException("agent did not answer in time", error);
|
||||
}
|
||||
catch (AgentPipeException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException
|
||||
or InvalidDataException)
|
||||
{
|
||||
throw new AgentPipeException("agent pipe failed: " + error.Message, error);
|
||||
}
|
||||
}
|
||||
|
||||
// One newline-terminated UTF-8 line, or null on EOF before any byte.
|
||||
// Throws InvalidDataException past maxBytes. Shared with the server.
|
||||
internal static async Task<string?> ReadLineAsync(Stream stream, int maxBytes, CancellationToken token)
|
||||
{
|
||||
var buffer = new MemoryStream();
|
||||
var chunk = new byte[4096];
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(chunk, token);
|
||||
if (read == 0)
|
||||
return buffer.Length == 0 ? null : Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
|
||||
|
||||
var newline = Array.IndexOf(chunk, (byte)'\n', 0, read);
|
||||
var take = newline >= 0 ? newline : read;
|
||||
if (buffer.Length + take > maxBytes)
|
||||
throw new InvalidDataException($"pipe message exceeds {maxBytes} bytes");
|
||||
buffer.Write(chunk, 0, take);
|
||||
if (newline >= 0)
|
||||
return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
562
agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs
Normal file
562
agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs
Normal file
@@ -0,0 +1,562 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// \\.\pipe\ShopDB.EndpointAgent.v1: the tray's only way to reach the server.
|
||||
// The tray holds no secret; it asks the SYSTEM agent to send status, upload a
|
||||
// report, list alerts or acknowledge one, and the agent does it with its own
|
||||
// credential. Protocol in AgentPipeProtocol / AgentPipeClient.
|
||||
//
|
||||
// ACL: SYSTEM and Administrators full control, INTERACTIVE read/write (no
|
||||
// create-instance, so a user cannot add instances to our pipe). The first
|
||||
// instance is created with FirstPipeInstance so a process that squatted the
|
||||
// name before the service started is refused rather than joined.
|
||||
public sealed class AgentPipeServer : BackgroundService
|
||||
{
|
||||
public const int MaxInstances = 8;
|
||||
|
||||
private const int MaxScreenshotBytes = 25 * 1024 * 1024;
|
||||
private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(40);
|
||||
|
||||
private static readonly JsonSerializerOptions PayloadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly AgentConfig config;
|
||||
private readonly TelemetryWorker telemetry;
|
||||
private readonly CollectorClient collector;
|
||||
private readonly FleetAlertClient alerts;
|
||||
private readonly ICredentialProvider credentials;
|
||||
private readonly string agentVersion;
|
||||
private readonly ILogger<AgentPipeServer> logger;
|
||||
private readonly SemaphoreSlim statusGate = new(1, 1);
|
||||
private readonly SemaphoreSlim reportGate = new(1, 1);
|
||||
|
||||
public AgentPipeServer(AgentConfig config, TelemetryWorker telemetry, CollectorClient collector,
|
||||
FleetAlertClient alerts, ICredentialProvider credentials, string agentVersion,
|
||||
ILogger<AgentPipeServer> logger)
|
||||
{
|
||||
this.config = config;
|
||||
this.telemetry = telemetry;
|
||||
this.collector = collector;
|
||||
this.alerts = alerts;
|
||||
this.credentials = credentials;
|
||||
this.agentVersion = agentVersion;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var slots = new SemaphoreSlim(MaxInstances, MaxInstances);
|
||||
bool first = true;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await slots.WaitAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
NamedPipeServerStream pipe;
|
||||
try
|
||||
{
|
||||
pipe = CreateInstance(first);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
slots.Release();
|
||||
logger.LogError(error,
|
||||
first
|
||||
? "Could not create the agent pipe; another process may own the name. Retrying in 30s."
|
||||
: "Could not create an agent pipe instance. Retrying in 30s.");
|
||||
if (!await SafeDelay(TimeSpan.FromSeconds(30), stoppingToken))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
logger.LogInformation("Agent pipe listening on {Pipe}", AgentPipeProtocol.PipeName);
|
||||
RestrictAppSettingsAcl();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.WaitForConnectionAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
pipe.Dispose();
|
||||
slots.Release();
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
logger.LogWarning(error, "Agent pipe connection failed");
|
||||
if (!await SafeDelay(TimeSpan.FromSeconds(1), stoppingToken))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleConnectionAsync(pipe, stoppingToken);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Agent pipe request ended with an error");
|
||||
}
|
||||
finally
|
||||
{
|
||||
pipe.Dispose();
|
||||
slots.Release();
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static NamedPipeServerStream CreateInstance(bool first)
|
||||
{
|
||||
var security = new PipeSecurity();
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
|
||||
PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
|
||||
PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.InteractiveSid, null),
|
||||
PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
|
||||
var options = PipeOptions.Asynchronous;
|
||||
if (first)
|
||||
options |= PipeOptions.FirstPipeInstance;
|
||||
|
||||
return NamedPipeServerStreamAcl.Create(AgentPipeProtocol.PipeName, PipeDirection.InOut,
|
||||
MaxInstances, PipeTransmissionMode.Byte, options, 4096, 4096, security);
|
||||
}
|
||||
|
||||
private async Task HandleConnectionAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
|
||||
{
|
||||
string? line;
|
||||
using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken))
|
||||
{
|
||||
readTimeout.CancelAfter(AgentPipeProtocol.RequestReadTimeout);
|
||||
try
|
||||
{
|
||||
line = await AgentPipeClient.ReadLineAsync(pipe, AgentPipeProtocol.MaxRequestBytes,
|
||||
readTimeout.Token);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
await WriteAsync(pipe, Failure("request too large"), stoppingToken);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug("Agent pipe client sent no request within {Timeout}",
|
||||
AgentPipeProtocol.RequestReadTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
return;
|
||||
|
||||
// Impersonation needs a completed read first, which we just did.
|
||||
var (callerName, callerSid) = IdentifyCaller(pipe);
|
||||
|
||||
string op;
|
||||
JsonElement request;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
request = doc.RootElement.Clone();
|
||||
op = request.ValueKind == JsonValueKind.Object
|
||||
&& request.TryGetProperty("op", out var opValue) && opValue.ValueKind == JsonValueKind.String
|
||||
? opValue.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
await WriteAsync(pipe, Failure("request is not valid JSON"), stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
object response;
|
||||
try
|
||||
{
|
||||
response = op switch
|
||||
{
|
||||
"ping" => Ping(),
|
||||
"sendstatus" => await SendStatusAsync(callerName, stoppingToken),
|
||||
"sendreport" => await SendReportAsync(request, callerName, callerSid, stoppingToken),
|
||||
"alerts" => await ListAlertsAsync(stoppingToken),
|
||||
"ackalert" => await AckAlertAsync(request, callerName, stoppingToken),
|
||||
_ => Failure("unknown op"),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
response = Failure("the agent timed out talking to ShopDB");
|
||||
}
|
||||
|
||||
await WriteAsync(pipe, response, stoppingToken);
|
||||
}
|
||||
|
||||
private (string? Name, SecurityIdentifier? Sid) IdentifyCaller(NamedPipeServerStream pipe)
|
||||
{
|
||||
string? name = null;
|
||||
SecurityIdentifier? sid = null;
|
||||
try
|
||||
{
|
||||
name = pipe.GetImpersonationUserName();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "GetImpersonationUserName failed");
|
||||
}
|
||||
try
|
||||
{
|
||||
pipe.RunAsClient(() =>
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent(TokenAccessLevels.Query);
|
||||
sid = identity.User;
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Could not read the pipe caller's SID");
|
||||
}
|
||||
return (name, sid);
|
||||
}
|
||||
|
||||
private object Ping()
|
||||
{
|
||||
var current = credentials.Current;
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
agentversion = agentVersion,
|
||||
enrolled = current is not null,
|
||||
assetid = current?.AssetId,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<object> SendStatusAsync(string? caller, CancellationToken stoppingToken)
|
||||
{
|
||||
if (!await statusGate.WaitAsync(0, stoppingToken))
|
||||
return Failure("a status send is already running");
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(LongOperationTimeout);
|
||||
logger.LogInformation("Status send requested over the pipe by {Caller}", caller);
|
||||
var result = await telemetry.RunOnceAsync(config.AdoptOnFirstRun, timeout.Token);
|
||||
logger.LogInformation("Pipe status send complete: action={Action}", result.Action);
|
||||
return Success();
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogWarning("Pipe status send failed: HTTP {Status}", error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(error, "Pipe status send failed");
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
finally
|
||||
{
|
||||
statusGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// Does what --report does, but only with files inside the CALLER's own
|
||||
// profile: the pipe is open to every interactive user and this process is
|
||||
// SYSTEM, so an unchecked path would upload any file SYSTEM can read. The
|
||||
// sidecar is not deleted here (the tray removes it); deleting in a user-
|
||||
// writable tree as SYSTEM is a redirection target.
|
||||
private async Task<object> SendReportAsync(JsonElement request, string? caller,
|
||||
SecurityIdentifier? callerSid, CancellationToken stoppingToken)
|
||||
{
|
||||
if (callerSid is null)
|
||||
return Failure("could not identify the calling user");
|
||||
|
||||
var spoolPath = request.TryGetProperty("spoolpath", out var pathValue)
|
||||
&& pathValue.ValueKind == JsonValueKind.String
|
||||
? pathValue.GetString()
|
||||
: null;
|
||||
if (string.IsNullOrWhiteSpace(spoolPath))
|
||||
return Failure("spoolpath is required");
|
||||
|
||||
var profile = ProfilePathFor(callerSid);
|
||||
if (profile is null)
|
||||
return Failure("no local profile for the calling user");
|
||||
|
||||
if (!await reportGate.WaitAsync(0, stoppingToken))
|
||||
return Failure("a report upload is already running");
|
||||
try
|
||||
{
|
||||
OperatorReportPayload? payload;
|
||||
try
|
||||
{
|
||||
using var sidecar = OpenCallerFile(spoolPath, profile, AgentPipeProtocol.MaxRequestBytes);
|
||||
payload = await JsonSerializer.DeserializeAsync<OperatorReportPayload>(
|
||||
sidecar, PayloadOptions, stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException
|
||||
or JsonException or ArgumentException
|
||||
or NotSupportedException)
|
||||
{
|
||||
logger.LogWarning("Refused report sidecar from {Caller}: {Reason}", caller, error.Message);
|
||||
return Failure("report file refused: " + error.Message);
|
||||
}
|
||||
if (payload is null)
|
||||
return Failure("report file is empty");
|
||||
|
||||
FileStream? screenshot = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(payload.ScreenshotPath))
|
||||
{
|
||||
if (!payload.ScreenshotPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
return Failure("screenshot must be a .png file");
|
||||
try
|
||||
{
|
||||
screenshot = OpenCallerFile(payload.ScreenshotPath, profile, MaxScreenshotBytes);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
logger.LogWarning("Refused report screenshot from {Caller}: {Reason}",
|
||||
caller, error.Message);
|
||||
return Failure("screenshot refused: " + error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(LongOperationTimeout);
|
||||
var uploaded = await collector.UploadOperatorReportAsync(
|
||||
payload.Reason, payload.Details, screenshot,
|
||||
payload.ScreenshotPath is null ? null : Path.GetFileName(payload.ScreenshotPath),
|
||||
Environment.MachineName, timeout.Token);
|
||||
logger.LogInformation("Operator report from {Caller} uploaded: id={ReportId}",
|
||||
caller, uploaded?.OperatorReportId);
|
||||
return Success();
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogWarning("Operator report upload failed: HTTP {Status}", error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(error, "Operator report upload failed");
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
finally
|
||||
{
|
||||
screenshot?.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
reportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> ListAlertsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(ShortOperationTimeout);
|
||||
var list = await alerts.FetchAlertsAsync(Environment.MachineName, timeout.Token);
|
||||
return new { ok = true, alerts = list.ToArray() };
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogDebug("Alert fetch for the tray failed: HTTP {Status}", error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException or JsonException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug(error, "Alert fetch for the tray failed");
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> AckAlertAsync(JsonElement request, string? caller, CancellationToken stoppingToken)
|
||||
{
|
||||
if (!request.TryGetProperty("notificationid", out var idValue)
|
||||
|| idValue.ValueKind != JsonValueKind.Number
|
||||
|| !idValue.TryGetInt32(out var notificationId)
|
||||
|| notificationId <= 0)
|
||||
return Failure("notificationid must be a positive integer");
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(ShortOperationTimeout);
|
||||
// 0 = "the caller": the server resolves it from credential or hostname.
|
||||
var assetId = credentials.Current?.AssetId ?? 0;
|
||||
await alerts.AcknowledgeAlertAsync(notificationId, assetId, Environment.MachineName, timeout.Token);
|
||||
logger.LogInformation("Alert {NotificationId} acknowledged by {Caller}", notificationId, caller);
|
||||
return Success();
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogWarning("Alert {NotificationId} acknowledge failed: HTTP {Status}",
|
||||
notificationId, error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(error, "Alert {NotificationId} acknowledge failed", notificationId);
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
}
|
||||
|
||||
// Profile root for a SID from ProfileList. Null when absent.
|
||||
private static string? ProfilePathFor(SecurityIdentifier sid)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var key = machine.OpenSubKey(
|
||||
@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + sid.Value);
|
||||
if (key?.GetValue("ProfileImagePath") is not string raw || string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
return Path.GetFullPath(Environment.ExpandEnvironmentVariables(raw)).TrimEnd('\\');
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Open a caller-named file only if it sits inside the caller's profile, no
|
||||
// directory from the profile down is a reparse point, the file itself is
|
||||
// not one, and the opened handle's final path is still inside the profile.
|
||||
private static FileStream OpenCallerFile(string path, string profile, int maxBytes)
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
throw new UnauthorizedAccessException("path must be a local absolute path");
|
||||
|
||||
var full = Path.GetFullPath(path);
|
||||
var root = profile + @"\";
|
||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
throw new UnauthorizedAccessException("path is outside the caller's profile");
|
||||
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
while (directory is not null && directory.Length >= profile.Length)
|
||||
{
|
||||
if (ProtectedFileSystem.IsReparsePoint(directory))
|
||||
throw new UnauthorizedAccessException("path crosses a reparse point");
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
var stream = ProtectedFileSystem.OpenReadNoFollow(full);
|
||||
try
|
||||
{
|
||||
var final = ProtectedFileSystem.FinalPath(stream.SafeFileHandle);
|
||||
if (final is null || !final.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
throw new UnauthorizedAccessException("path resolves outside the caller's profile");
|
||||
if (stream.Length > maxBytes)
|
||||
throw new IOException($"file is larger than {maxBytes} bytes");
|
||||
return stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Only SYSTEM (the service) and Administrators read appsettings.json now:
|
||||
// no user-context process needs it once the tray talks through this pipe.
|
||||
private void RestrictAppSettingsAcl()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
if (!identity.IsSystem)
|
||||
return;
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (ProtectedFileSystem.TryGetAttributes(path) is not FileAttributes attributes
|
||||
|| attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return;
|
||||
|
||||
var security = new FileSecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
|
||||
FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
|
||||
FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
new FileInfo(path).SetAccessControl(security);
|
||||
logger.LogInformation("appsettings.json restricted to SYSTEM and Administrators.");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Could not restrict the appsettings.json ACL");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ServerFailure(CollectorException error)
|
||||
=> string.IsNullOrEmpty(error.ServerCode)
|
||||
? $"ShopDB returned HTTP {error.StatusCode}"
|
||||
: $"ShopDB returned HTTP {error.StatusCode} {error.ServerCode}";
|
||||
|
||||
private static object Success() => new { ok = true };
|
||||
|
||||
private static object Failure(string error) => new { ok = false, error };
|
||||
|
||||
private static async Task WriteAsync(Stream pipe, object response, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
|
||||
await pipe.WriteAsync(bytes, token);
|
||||
await pipe.WriteAsync(new[] { (byte)'\n' }, token);
|
||||
await pipe.FlushAsync(token);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Client went away.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> SafeDelay(TimeSpan delay, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, token);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks client certificate expiry.
|
||||
/// If certificate expires within 30 days, triggers renewal and service restart.
|
||||
///
|
||||
/// This is critical for long-running agents that don't restart frequently.
|
||||
/// Without this, an agent running > 30 days will have an expired cert with no
|
||||
/// automatic renewal, causing authentication failures.
|
||||
/// </summary>
|
||||
public class CertificateExpiryMonitor : BackgroundService
|
||||
{
|
||||
private readonly ILogger<CertificateExpiryMonitor> logger;
|
||||
private readonly ILogger<CertificateRenewal> renewalLogger;
|
||||
private readonly AgentConfig config;
|
||||
private readonly IHostApplicationLifetime lifetime;
|
||||
|
||||
private const int CHECK_INTERVAL_HOURS = 24;
|
||||
private const int RENEWAL_THRESHOLD_DAYS = 30;
|
||||
|
||||
public CertificateExpiryMonitor(
|
||||
ILogger<CertificateExpiryMonitor> logger,
|
||||
ILogger<CertificateRenewal> renewalLogger,
|
||||
AgentConfig config,
|
||||
IHostApplicationLifetime lifetime)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.renewalLogger = renewalLogger;
|
||||
this.config = config;
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait 1 hour after service start before first check (let agent stabilize)
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckCertificateExpiry();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Certificate expiry check failed");
|
||||
}
|
||||
|
||||
// Check every 24 hours
|
||||
await Task.Delay(TimeSpan.FromHours(CHECK_INTERVAL_HOURS), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckCertificateExpiry()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
// No client cert configured, nothing to check
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogDebug("Performing periodic certificate expiry check");
|
||||
|
||||
// Use same renewal logic as startup check
|
||||
var renewal = new CertificateRenewal(renewalLogger, config);
|
||||
bool didRenew = await renewal.CheckAndRenewIfNeeded();
|
||||
|
||||
if (didRenew)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Certificate was renewed during periodic check. Agent has been running " +
|
||||
"for an extended period without restart. Stopping service to apply new " +
|
||||
"certificate. Service manager will restart the agent.");
|
||||
|
||||
// Stop the application gracefully
|
||||
// Windows Service Manager will restart it (if configured as Automatic)
|
||||
lifetime.StopApplication();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Automatic client certificate renewal when expiring within 30 days.
|
||||
/// Agent checks cert expiry on startup, requests renewal from server, installs
|
||||
/// new cert, updates config, and schedules service restart.
|
||||
/// </summary>
|
||||
public class CertificateRenewal
|
||||
{
|
||||
private readonly ILogger<CertificateRenewal> logger;
|
||||
private readonly AgentConfig config;
|
||||
|
||||
private const int RENEWAL_THRESHOLD_DAYS = 30;
|
||||
private const string RENEWAL_ENDPOINT = "/api/endpoints/certificate/renew";
|
||||
|
||||
public CertificateRenewal(ILogger<CertificateRenewal> logger, AgentConfig config)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if client certificate needs renewal (expires in < 30 days).
|
||||
/// Called on agent startup before first telemetry report.
|
||||
/// </summary>
|
||||
public async Task<bool> CheckAndRenewIfNeeded()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
// No cert configured, skip renewal check
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find current certificate
|
||||
var cert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Certificate {Thumbprint} not found in LocalMachine\\My store. " +
|
||||
"Cannot check expiry or renew.",
|
||||
config.ClientCertThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
var daysRemaining = (cert.NotAfter - DateTime.Now).TotalDays;
|
||||
logger.LogInformation(
|
||||
"Client certificate expires {ExpiryDate} ({DaysRemaining:F1} days remaining)",
|
||||
cert.NotAfter, daysRemaining);
|
||||
|
||||
if (daysRemaining > RENEWAL_THRESHOLD_DAYS)
|
||||
{
|
||||
// Certificate still valid for > 30 days, no renewal needed
|
||||
return false;
|
||||
}
|
||||
|
||||
// Certificate expiring soon or already expired, request renewal
|
||||
logger.LogWarning(
|
||||
"Certificate expires in {DaysRemaining:F1} days (threshold {Threshold} days). " +
|
||||
"Requesting renewal from server...",
|
||||
daysRemaining, RENEWAL_THRESHOLD_DAYS);
|
||||
|
||||
try
|
||||
{
|
||||
await RenewCertificate(cert);
|
||||
return true; // Renewal succeeded, service should restart
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Certificate renewal failed. Agent will continue with existing cert " +
|
||||
"until next startup or manual intervention.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
// Thumbprint lookup is case-insensitive, no separators
|
||||
var normalizedThumbprint = thumbprint.Replace(":", "").Replace(" ", "");
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
normalizedThumbprint,
|
||||
validOnly: false); // Include expired certs for renewal
|
||||
|
||||
return certs.Count > 0 ? certs[0] : null;
|
||||
}
|
||||
|
||||
private async Task RenewCertificate(X509Certificate2 oldCert)
|
||||
{
|
||||
// Step 1: Request new certificate from server
|
||||
var response = await RequestCertificateFromServer(oldCert);
|
||||
if (response == null)
|
||||
{
|
||||
throw new InvalidOperationException("Server returned null renewal response");
|
||||
}
|
||||
|
||||
// Step 2: Import certificate to LocalMachine\My store
|
||||
var newCert = ImportPfxCertificate(response.Pfx, response.Password);
|
||||
|
||||
// Step 3: Update appsettings.json with new thumbprint
|
||||
UpdateConfigFile(response.Thumbprint);
|
||||
|
||||
// Step 4: Grant Users group access to private key (for tray app)
|
||||
GrantPrivateKeyAccess(newCert);
|
||||
|
||||
logger.LogInformation(
|
||||
"Certificate renewal complete. New cert thumbprint: {Thumbprint}, " +
|
||||
"expires: {ExpiryDate}. Service restart required.",
|
||||
response.Thumbprint, response.ExpiresAt);
|
||||
}
|
||||
|
||||
private async Task<RenewalResponse?> RequestCertificateFromServer(X509Certificate2 currentCert)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
// Attach current (expiring) certificate to authenticate renewal request
|
||||
handler.ClientCertificates.Add(currentCert);
|
||||
|
||||
// Server certificate validation: MUST validate, never accept blindly
|
||||
handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogError("Server provided no certificate during renewal request");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var actualThumbprint = cert.GetCertHashString();
|
||||
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
|
||||
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError(
|
||||
"Server certificate thumbprint mismatch during renewal. " +
|
||||
"Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: validate against Windows trusted roots
|
||||
// This prevents MITM attacks when pinning is not explicitly configured
|
||||
if (errors == System.Net.Security.SslPolicyErrors.None)
|
||||
{
|
||||
// Certificate is valid and trusted by Windows
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Server certificate validation failed during renewal: {Errors}. " +
|
||||
"Configure ServerCertThumbprint for certificate pinning or ensure " +
|
||||
"server certificate is trusted by Windows certificate store.",
|
||||
errors);
|
||||
return false;
|
||||
};
|
||||
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
// Add X-API-Key header for API key fallback (when cert is expired and nginx rejects it)
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
|
||||
var url = config.BaseUrl.TrimEnd('/') + RENEWAL_ENDPOINT;
|
||||
|
||||
logger.LogInformation("Requesting certificate renewal from {Url}", url);
|
||||
|
||||
// Send hostname in request body for API key fallback authentication
|
||||
var requestBody = JsonSerializer.Serialize(new { hostname = Environment.MachineName });
|
||||
var httpResponse = await http.PostAsync(url,
|
||||
new StringContent(requestBody, Encoding.UTF8, "application/json"));
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await httpResponse.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException(
|
||||
$"Certificate renewal request failed: {httpResponse.StatusCode} - {errorBody}");
|
||||
}
|
||||
|
||||
var json = await httpResponse.Content.ReadAsStringAsync();
|
||||
var envelope = JsonSerializer.Deserialize<RenewalResponseEnvelope>(json);
|
||||
if (envelope?.Data == null)
|
||||
{
|
||||
throw new InvalidOperationException("Server returned invalid renewal response");
|
||||
}
|
||||
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private X509Certificate2 ImportPfxCertificate(string pfxBase64, string password)
|
||||
{
|
||||
var pfxBytes = Convert.FromBase64String(pfxBase64);
|
||||
|
||||
// Import to LocalMachine\My store
|
||||
var cert = new X509Certificate2(pfxBytes, password,
|
||||
X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet);
|
||||
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadWrite);
|
||||
store.Add(cert);
|
||||
|
||||
logger.LogInformation(
|
||||
"Imported new certificate to LocalMachine\\My: {Subject}, thumbprint: {Thumbprint}",
|
||||
cert.Subject, cert.Thumbprint);
|
||||
|
||||
return cert;
|
||||
}
|
||||
|
||||
private void UpdateConfigFile(string newThumbprint)
|
||||
{
|
||||
// appsettings.json location: same directory as executable
|
||||
var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Configuration file not found: {configPath}");
|
||||
}
|
||||
|
||||
// Read, parse, update, write back
|
||||
var json = File.ReadAllText(configPath);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
|
||||
|
||||
writer.WriteStartObject();
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Name.Equals("clientcertthumbprint", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteString(prop.Name, newThumbprint);
|
||||
}
|
||||
else
|
||||
{
|
||||
prop.WriteTo(writer);
|
||||
}
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
var updatedJson = Encoding.UTF8.GetString(stream.ToArray());
|
||||
File.WriteAllText(configPath, updatedJson);
|
||||
|
||||
logger.LogInformation("Updated {ConfigPath} with new certificate thumbprint", configPath);
|
||||
}
|
||||
|
||||
private void GrantPrivateKeyAccess(X509Certificate2 cert)
|
||||
{
|
||||
// Grant BUILTIN\Users read access to private key so tray app can use it
|
||||
try
|
||||
{
|
||||
var rsaKey = cert.GetRSAPrivateKey();
|
||||
if (rsaKey == null)
|
||||
{
|
||||
logger.LogWarning("Certificate has no RSA private key, cannot set permissions");
|
||||
return;
|
||||
}
|
||||
|
||||
// CNG key: use key name to find file
|
||||
if (rsaKey is System.Security.Cryptography.RSACng cngKey)
|
||||
{
|
||||
var keyPath = cngKey.Key.UniqueName;
|
||||
var searchPaths = new[]
|
||||
{
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
@"Microsoft\Crypto\SystemKeys"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
@"Microsoft\Crypto\Keys"),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
@"Microsoft\Crypto\RSA\MachineKeys")
|
||||
};
|
||||
|
||||
foreach (var searchPath in searchPaths)
|
||||
{
|
||||
var keyFile = Path.Combine(searchPath, keyPath);
|
||||
if (File.Exists(keyFile))
|
||||
{
|
||||
var fileInfo = new FileInfo(keyFile);
|
||||
var acl = fileInfo.GetAccessControl();
|
||||
var rule = new FileSystemAccessRule(
|
||||
"BUILTIN\\Users",
|
||||
FileSystemRights.Read | FileSystemRights.ReadAndExecute,
|
||||
AccessControlType.Allow);
|
||||
acl.AddAccessRule(rule);
|
||||
fileInfo.SetAccessControl(acl);
|
||||
|
||||
logger.LogInformation("Granted Users group access to private key at {KeyFile}", keyFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogWarning("Private key file not found in any search path");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not set private key permissions (tray app may not work)");
|
||||
}
|
||||
}
|
||||
|
||||
// JSON response schema
|
||||
private class RenewalResponseEnvelope
|
||||
{
|
||||
public RenewalResponse? Data { get; set; }
|
||||
}
|
||||
|
||||
private class RenewalResponse
|
||||
{
|
||||
public string Pfx { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public string Thumbprint { get; set; } = "";
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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<CollectorClient> logger,
|
||||
private readonly ICredentialProvider credentials;
|
||||
|
||||
public CollectorClient(AgentHttpFactory httpFactory, ILogger<CollectorClient> 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<CollectorResponse> SendAsync(
|
||||
@@ -184,8 +75,12 @@ public sealed class CollectorClient : IDisposable
|
||||
private async Task<CollectorResponse> 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,32 +137,48 @@ 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<OperatorReportUploadResponse?> 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<OperatorReportUploadResponse?> 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);
|
||||
// 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",
|
||||
Path.GetFileName(screenshotPath));
|
||||
form.Add(imageContent, "screenshot", screenshotName ?? "screenshot.png");
|
||||
}
|
||||
|
||||
using var response = await http.PostAsync(
|
||||
@@ -280,11 +191,23 @@ public sealed class CollectorClient : IDisposable
|
||||
body, JsonOptions);
|
||||
return envelope?.Data;
|
||||
}
|
||||
finally
|
||||
|
||||
private sealed class NonClosingStream : Stream
|
||||
{
|
||||
imageContent?.Dispose();
|
||||
imageStream?.Dispose();
|
||||
}
|
||||
private readonly Stream inner;
|
||||
public NonClosingStream(Stream inner) => this.inner = inner;
|
||||
public override bool CanRead => inner.CanRead;
|
||||
public override bool CanSeek => inner.CanSeek;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => inner.Length;
|
||||
public override long Position { get => inner.Position; set => inner.Position = value; }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> inner.ReadAsync(buffer, cancellationToken);
|
||||
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CommandClient> logger;
|
||||
|
||||
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
|
||||
public CommandClient(AgentHttpFactory httpFactory, ILogger<CommandClient> 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
|
||||
|
||||
@@ -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<CommandExecutor> 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);
|
||||
|
||||
@@ -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<CommandWorker> 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();
|
||||
|
||||
318
agent/ShopDbEndpointAgent.Core/EnrollmentService.cs
Normal file
318
agent/ShopDbEndpointAgent.Core/EnrollmentService.cs
Normal file
@@ -0,0 +1,318 @@
|
||||
using System.Management;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Gets this PC its own endpoint credential (POST /api/endpoints/enroll) and
|
||||
// keeps trying until it has one. Workers run on the shared key meanwhile, so an
|
||||
// unenrolled or pending PC keeps reporting.
|
||||
//
|
||||
// 201 enrolled store credential, swap provider, idle until woken
|
||||
// 202 pending retry every 15 min +/- 2 min (admin approval, serial conflict)
|
||||
// 404 server predates enrollment: idle, retry every 6 h
|
||||
// other back off 1, 5, 15 min (cap)
|
||||
//
|
||||
// Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on
|
||||
// SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute.
|
||||
public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
public const string DeviceIdValue = "DeviceId";
|
||||
|
||||
private static readonly TimeSpan PendingRetry = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan PendingJitter = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan[] ErrorBackoff =
|
||||
{
|
||||
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15),
|
||||
};
|
||||
private static readonly TimeSpan MinWakeInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly AgentConfig config;
|
||||
private readonly AgentHttpFactory httpFactory;
|
||||
private readonly AgentCredentialProvider credentials;
|
||||
private readonly CredentialStore store;
|
||||
private readonly string agentVersion;
|
||||
private readonly ILogger<EnrollmentService> logger;
|
||||
private readonly SemaphoreSlim wake = new(0, 1);
|
||||
private readonly TaskCompletionSource firstAttempt =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly Random jitter = new();
|
||||
|
||||
public EnrollmentService(AgentConfig config, AgentHttpFactory httpFactory,
|
||||
AgentCredentialProvider credentials, CredentialStore store, string agentVersion,
|
||||
ILogger<EnrollmentService> logger)
|
||||
{
|
||||
this.config = config;
|
||||
this.httpFactory = httpFactory;
|
||||
this.credentials = credentials;
|
||||
this.store = store;
|
||||
this.agentVersion = agentVersion;
|
||||
this.logger = logger;
|
||||
credentials.EnrollmentNeeded += (_, _) => Wake();
|
||||
}
|
||||
|
||||
// Workers await this (bounded) before first contact so a PC that can enroll
|
||||
// right away does its first report on its own credential.
|
||||
public async Task WaitForFirstAttemptAsync(TimeSpan limit, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await firstAttempt.Task.WaitAsync(limit, token);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Wake()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (wake.CurrentCount == 0)
|
||||
wake.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
int failures = 0;
|
||||
var lastAttempt = DateTime.MinValue;
|
||||
using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (credentials.Current is not null)
|
||||
{
|
||||
firstAttempt.TrySetResult();
|
||||
await wake.WaitAsync(stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sinceLast = DateTime.UtcNow - lastAttempt;
|
||||
if (sinceLast < MinWakeInterval)
|
||||
await Task.Delay(MinWakeInterval - sinceLast, stoppingToken);
|
||||
|
||||
lastAttempt = DateTime.UtcNow;
|
||||
var outcome = await TryEnrollAsync(client, stoppingToken);
|
||||
firstAttempt.TrySetResult();
|
||||
|
||||
TimeSpan delay;
|
||||
switch (outcome)
|
||||
{
|
||||
case EnrollOutcome.Enrolled:
|
||||
failures = 0;
|
||||
continue;
|
||||
case EnrollOutcome.Pending:
|
||||
failures = 0;
|
||||
delay = PendingRetry + TimeSpan.FromSeconds(
|
||||
(jitter.NextDouble() * 2 - 1) * PendingJitter.TotalSeconds);
|
||||
break;
|
||||
case EnrollOutcome.Unsupported:
|
||||
failures = 0;
|
||||
delay = UnsupportedRetry;
|
||||
break;
|
||||
default:
|
||||
delay = ErrorBackoff[Math.Min(failures, ErrorBackoff.Length - 1)];
|
||||
failures++;
|
||||
break;
|
||||
}
|
||||
|
||||
// A wake cuts the wait short (credential refused, shared key refused).
|
||||
await wake.WaitAsync(delay, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstAttempt.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed }
|
||||
|
||||
private async Task<EnrollOutcome> TryEnrollAsync(HttpClient client, CancellationToken token)
|
||||
{
|
||||
var hostname = Environment.MachineName;
|
||||
var serial = ReadBiosSerial();
|
||||
if (string.IsNullOrWhiteSpace(serial))
|
||||
{
|
||||
logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS.");
|
||||
return EnrollOutcome.Failed;
|
||||
}
|
||||
|
||||
string nonce;
|
||||
try
|
||||
{
|
||||
nonce = store.GetOrCreateEnrollNonce();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Enrollment skipped: could not read or create enroll.json.");
|
||||
return EnrollOutcome.Failed;
|
||||
}
|
||||
|
||||
var request = new
|
||||
{
|
||||
hostname,
|
||||
serialnumber = serial,
|
||||
deviceid = ReadDeviceId(),
|
||||
enrollnonce = nonce,
|
||||
agentversion = agentVersion,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.PostAsJsonAsync("api/endpoints/enroll", request, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound
|
||||
&& AgentServerError.TryParse(body)?.Code is null or "" or "NOT_FOUND")
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.",
|
||||
UnsupportedRetry);
|
||||
return EnrollOutcome.Unsupported;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Created)
|
||||
{
|
||||
var result = ParseResult(body);
|
||||
var secret = result.GetString("credential");
|
||||
if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogError("Enrollment returned 201 without a usable credential.");
|
||||
return EnrollOutcome.Failed;
|
||||
}
|
||||
|
||||
var credential = new AgentCredential
|
||||
{
|
||||
Credential = secret,
|
||||
AssetId = result.GetInt("assetid") ?? 0,
|
||||
IssuedAt = DateTime.UtcNow.ToString("o"),
|
||||
BaseUrl = config.BaseUrl.Trim().TrimEnd('/'),
|
||||
};
|
||||
try
|
||||
{
|
||||
credentials.Set(credential);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Shown once by the server; an admin must revoke and let it re-enroll.
|
||||
logger.LogError(error,
|
||||
"Enrolled as asset {AssetId} but could not store credential {Prefix}. " +
|
||||
"Revoke it in ShopDB so this PC can enroll again.",
|
||||
credential.AssetId, credential.Prefix);
|
||||
return EnrollOutcome.Failed;
|
||||
}
|
||||
return EnrollOutcome.Enrolled;
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
var result = ParseResult(body);
|
||||
logger.LogInformation(
|
||||
"Enrollment pending (request {RequestId}): {Reason}.",
|
||||
result.GetInt("requestid"), result.GetString("reason") ?? "no reason given");
|
||||
return EnrollOutcome.Pending;
|
||||
}
|
||||
|
||||
var refusal = AgentServerError.TryParse(body);
|
||||
if (refusal?.Code == "ENROLL_NONCE_MISMATCH")
|
||||
{
|
||||
logger.LogError(
|
||||
"Enrollment refused: the approved request belongs to a different enroll nonce. " +
|
||||
"An admin must reject it so this PC can request again.");
|
||||
return EnrollOutcome.Pending;
|
||||
}
|
||||
|
||||
logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}",
|
||||
(int)response.StatusCode, refusal?.Code, refusal?.Message);
|
||||
return EnrollOutcome.Failed;
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is HttpRequestException or TaskCanceledException or JsonException
|
||||
&& !token.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("Enrollment request failed: {Message}", error.Message);
|
||||
return EnrollOutcome.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts the bare body or the success envelope's data object.
|
||||
private static ResultReader ParseResult(string body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
root = data;
|
||||
return new ResultReader(root.Clone());
|
||||
}
|
||||
|
||||
private readonly struct ResultReader
|
||||
{
|
||||
private readonly JsonElement root;
|
||||
public ResultReader(JsonElement root) => this.root = root;
|
||||
|
||||
public string? GetString(string name)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() : null;
|
||||
|
||||
public int? GetInt(string name)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
&& value.TryGetInt32(out var number)
|
||||
? number : null;
|
||||
}
|
||||
|
||||
private string? ReadBiosSerial()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS");
|
||||
foreach (var item in searcher.Get())
|
||||
{
|
||||
var value = item["SerialNumber"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value.Length > 128 ? value[..128] : value;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "WMI Win32_BIOS query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ReadDeviceId()
|
||||
{
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
|
||||
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
|
||||
if (key?.GetValue(DeviceIdValue) is string value && !string.IsNullOrWhiteSpace(value))
|
||||
return value.Trim();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Registry read of {Value} in {View} view failed", DeviceIdValue, view);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
private sealed class SharedKeyOnlyProvider : ICredentialProvider
|
||||
{
|
||||
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)
|
||||
{
|
||||
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<List<FleetAlert>> FetchAlertsAsync(
|
||||
@@ -139,6 +62,9 @@ public sealed class FleetAlertClient : IDisposable
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
|
||||
body, JsonOptions);
|
||||
if (envelope?.Data is FleetAlertList parsed)
|
||||
foreach (var skipped in parsed.Skipped)
|
||||
logger.LogWarning("Skipped malformed fleet alert: {Reason}", skipped);
|
||||
return envelope?.Data ?? new List<FleetAlert>();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<!-- DPAPI LocalMachine for the per-PC credential (CredentialStore). -->
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
<!-- EventWaitHandleAcl: creating the user-context refresh event with an
|
||||
explicit DACL. The service creates it as LocalSystem, and without a
|
||||
granted ACE the unelevated tray cannot open it. -->
|
||||
<PackageReference Include="System.Threading.AccessControl" Version="8.0.0" />
|
||||
<!-- FileSystem.AccessControl: setting ACLs on private key files for certificate
|
||||
renewal (File.GetAccessControl/SetAccessControl moved to this package in .NET 8+) -->
|
||||
<!-- FileSystem.AccessControl: protected DACLs on staging, credential and
|
||||
appsettings.json files (ProtectedFileSystem, AgentPipeServer). -->
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<TelemetryWorker> 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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. -->
|
||||
<Version>1.6.11</Version>
|
||||
<!-- 1.7.0: the tray holds no credential. Fleet alerts, alert acknowledgement,
|
||||
Send Status and Report an issue all go through the SYSTEM agent's local
|
||||
pipe (ShopDB.EndpointAgent.v1) instead of reading the collector key and
|
||||
config or shelling the agent exe with the once and report verbs, so a
|
||||
standard user on the bay can no longer lift the fleet key through the
|
||||
tray's access path. The state.json asset-id reader is gone (the agent
|
||||
knows its own identity). An unreachable agent shows the agent-not-running
|
||||
balloon, and alert polling logs at a throttled rate and retries. An alert
|
||||
is recorded as acknowledged only when the agent confirms it, so a failed
|
||||
ack lets it reappear. The collect-usercontext probe is unchanged.
|
||||
Requires the agent release that hosts the pipe. -->
|
||||
<Version>1.7.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -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,24 +499,32 @@ 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;
|
||||
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<Dictionary<string, object>>(json);
|
||||
if (state is not null && state.TryGetValue("assetId", out var value))
|
||||
{
|
||||
if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
return element.GetInt32();
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not resolve asset ID from state file.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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 <path> 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<int> RunPipeClientAsync(bool runOnce, string? reportPath)
|
||||
{
|
||||
var client = new AgentPipeClient();
|
||||
var response = runOnce
|
||||
? await client.SendStatusAsync(CancellationToken.None)
|
||||
: await client.SendReportAsync(Path.GetFullPath(reportPath!), CancellationToken.None);
|
||||
if (response.Ok)
|
||||
return 0;
|
||||
Console.Error.WriteLine("shopdbagent: " + response.Error);
|
||||
return response.Data is null ? 3 : 1;
|
||||
}
|
||||
|
||||
// Bootstrap logger first so config loading (including the registry read that
|
||||
// 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<int> 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<CertificateRenewal>();
|
||||
var renewal = new CertificateRenewal(renewalLogger, config);
|
||||
|
||||
try
|
||||
{
|
||||
// Task.Run so a slow synchronous prefix cannot dodge the budget either.
|
||||
var renewalTask = Task.Run(renewal.CheckAndRenewIfNeeded);
|
||||
if (await Task.WhenAny(renewalTask, Task.Delay(renewalBudget)) != renewalTask)
|
||||
{
|
||||
renewalLogger.LogWarning(
|
||||
"Certificate renewal check still running after {Budget}; starting agent " +
|
||||
"with existing certificate. The expiry monitor retries later.",
|
||||
renewalBudget);
|
||||
}
|
||||
else if (await renewalTask)
|
||||
{
|
||||
renewalLogger.LogInformation(
|
||||
"Certificate renewed successfully. Service will exit with code {Code} " +
|
||||
"so service manager recovery restarts it with the new certificate.",
|
||||
RestartForRenewedCertificate);
|
||||
// Return without starting the host. The new process picks up the
|
||||
// updated thumbprint from appsettings.json. Safer than
|
||||
// Environment.Exit, which aborts in-flight work.
|
||||
return RestartForRenewedCertificate;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
renewalLogger.LogError(ex,
|
||||
"Certificate renewal check failed. Starting agent with existing certificate.");
|
||||
// Continue startup with existing (possibly expiring) certificate rather
|
||||
// than blocking service start. Next startup will retry renewal.
|
||||
}
|
||||
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
if (!forceConsole)
|
||||
@@ -226,16 +223,43 @@ static async Task<int> 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<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<AgentCredentialProvider>(),
|
||||
sp.GetRequiredService<CredentialStore>(),
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<EnrollmentService>>()));
|
||||
builder.Services.AddSingleton(sp => new AgentPipeServer(
|
||||
config,
|
||||
sp.GetRequiredService<TelemetryWorker>(),
|
||||
sp.GetRequiredService<CollectorClient>(),
|
||||
sp.GetRequiredService<FleetAlertClient>(),
|
||||
sp.GetRequiredService<ICredentialProvider>(),
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<AgentPipeServer>>()));
|
||||
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<EnrollmentService>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<AgentPipeServer>());
|
||||
|
||||
// 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<CertificateExpiryMonitor>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CertificateExpiryMonitor>());
|
||||
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<AgentCredentialProvider>();
|
||||
var collectSignal = host.Services.GetRequiredService<CollectSignal>();
|
||||
credentials.Changed += (_, _) =>
|
||||
{
|
||||
if (credentials.Current is not null)
|
||||
collectSignal.Request();
|
||||
};
|
||||
|
||||
await host.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -259,8 +283,8 @@ static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
|
||||
}
|
||||
|
||||
// Uploads one operator issue report described by a tray-written JSON sidecar,
|
||||
// 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<int> RunReportAsync(AgentConfig config, string payloadPath,
|
||||
string logDirectory, AgentFaultMonitor faultMonitor)
|
||||
@@ -371,8 +395,28 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
sp.GetRequiredService<EventWatermarkStore>(),
|
||||
sp.GetRequiredService<UserContextSpool>()));
|
||||
|
||||
// Per-PC credential (if enrolled) or shared key, swapped at runtime. One
|
||||
// factory builds every HttpClient so pins and auth cannot be skipped.
|
||||
services.AddSingleton(sp => new CredentialStore(
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Credentials")));
|
||||
services.AddSingleton(sp => new AgentCredentialProvider(
|
||||
config,
|
||||
sp.GetRequiredService<CredentialStore>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Credentials")));
|
||||
services.AddSingleton<ICredentialProvider>(sp =>
|
||||
sp.GetRequiredService<AgentCredentialProvider>());
|
||||
services.AddSingleton(sp => new AgentHttpFactory(
|
||||
config,
|
||||
sp.GetRequiredService<ICredentialProvider>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Http")));
|
||||
|
||||
services.AddSingleton(sp => new CollectorClient(
|
||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
|
||||
services.AddSingleton(sp => new FleetAlertClient(
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(FleetAlertClient))));
|
||||
|
||||
services.AddSingleton(sp => new TelemetryWorker(
|
||||
sp.GetRequiredService<ICollector>(),
|
||||
@@ -383,24 +427,28 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<CollectSignal>(),
|
||||
sp.GetRequiredService<UserContextSpool>(),
|
||||
sp.GetRequiredService<UserContextRefreshChannel>()));
|
||||
sp.GetRequiredService<UserContextRefreshChannel>(),
|
||||
sp.GetService<EnrollmentService>()));
|
||||
|
||||
// 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<ILogger<CommandClient>>()));
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<ILogger<CommandClient>>()));
|
||||
|
||||
services.AddSingleton(sp => new CommandExecutor(
|
||||
sp.GetRequiredService<ILogger<CommandExecutor>>(),
|
||||
config));
|
||||
config,
|
||||
sp.GetRequiredService<AgentHttpFactory>()));
|
||||
|
||||
services.AddSingleton(sp => new CommandWorker(
|
||||
sp.GetRequiredService<CommandClient>(),
|
||||
sp.GetRequiredService<CommandExecutor>(),
|
||||
sp.GetRequiredService<ILogger<CommandWorker>>(),
|
||||
sp.GetRequiredService<CollectSignal>(),
|
||||
config));
|
||||
config,
|
||||
sp.GetService<EnrollmentService>()));
|
||||
}
|
||||
|
||||
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
|
||||
|
||||
@@ -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. -->
|
||||
<Version>1.19.0</Version>
|
||||
<!-- 1.20.0: per-PC endpoint credentials. The shared fleet collector key was
|
||||
readable by any standard user on every bay, so one bay leaked the whole
|
||||
fleet. The agent now enrolls itself (POST /api/endpoints/enroll with
|
||||
hostname, BIOS serial, DeviceId and a persisted nonce) and receives its
|
||||
own credential, stored DPAPI LocalMachine under a SYSTEM/Administrators
|
||||
directory and sent as X-Endpoint-Credential. The shared key stays the
|
||||
fallback until the PC is enrolled, so the build also runs against an
|
||||
older server (a 404 on enroll parks enrollment for 6 hours). A revoked
|
||||
credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without
|
||||
a restart. Server trust moves to SPKI SHA-256 pins (ServerPins registry
|
||||
value and serverpins in appsettings.json), checked by one AgentHttpFactory
|
||||
that now builds every HttpClient, including script and package downloads
|
||||
that previously had their own unpinned handlers. The tray holds no
|
||||
secret any more: it reaches the service over the local named pipe
|
||||
ShopDB.EndpointAgent.v1 (ping, sendstatus, sendreport, alerts, ackalert),
|
||||
and the once and report switches become pipe clients when not SYSTEM. A report path must sit inside the
|
||||
caller's own profile. appsettings.json is restricted to SYSTEM and
|
||||
Administrators once the pipe is up. The 1.19 client-certificate renewal
|
||||
and expiry monitor are removed, and with them the non-zero exit at start.
|
||||
Pairs with tray 1.7.0. -->
|
||||
<Version>1.20.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"baseurl": "",
|
||||
"apikey": "REPLACE_WITH_COLLECTOR_KEY",
|
||||
"serverpins": [],
|
||||
"cadenceseconds": 900,
|
||||
"jitterseconds": 60,
|
||||
"startupjitterseconds": 30,
|
||||
|
||||
Reference in New Issue
Block a user