Files
PatchProbe-Server/PatchProbe.Cli/Program.cs
2026-05-25 10:29:38 +08:00

245 lines
11 KiB
C#

using System.CommandLine;
using System.CommandLine.Invocation;
using System.Security.Principal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PatchProbe.Cli.Auth;
using PatchProbe.Cli.Collectors;
using PatchProbe.Cli.Services;
using PatchProbe.Shared.Contracts;
using PatchProbe.Shared.Models;
using PatchProbe.Shared.Serialization;
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/patchprobe.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.CreateLogger();
try
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSerilog();
builder.Services.AddHttpClient();
// Collectors
builder.Services.AddSingleton<DeviceCollector>();
builder.Services.AddSingleton<OsCollector>();
builder.Services.AddSingleton<WindowsUpdateCollector>();
builder.Services.AddSingleton<PendingRebootCollector>();
builder.Services.AddSingleton<DriverCollector>();
builder.Services.AddSingleton<CbsDismCollector>();
builder.Services.AddSingleton<WindowsUpdatePolicyCollector>();
builder.Services.AddSingleton<HotfixCollector>();
builder.Services.AddSingleton<EventCollector>();
// Auth
builder.Services.AddSingleton<IDeviceCredentialStore, DpapiCredentialStore>();
builder.Services.AddSingleton<IRequestAuthenticator, EcdsaRequestAuthenticator>();
// Services
builder.Services.AddSingleton<EnrollmentService>();
builder.Services.AddSingleton<PayloadUploader>();
builder.Services.AddSingleton<IPayloadUploader>(sp => sp.GetRequiredService<PayloadUploader>());
var host = builder.Build();
var rootCommand = new RootCommand("PatchProbe — Windows patch-management evidence collector");
// ── scan ──────────────────────────────────────────────────────────────
var scanCommand = new Command("scan", "Collect patch-management evidence from this endpoint");
var outputOption = new Option<string?>("--output") { Description = "Path to write the JSON payload to disk" };
var noUploadOption = new Option<bool>("--no-upload") { Description = "Collect evidence and print JSON to stdout instead of uploading" };
var serverUrlOption = new Option<string?>("--server-url") { Description = "PatchProbe server URL to upload to (overrides enrolled server URL)" };
scanCommand.Add(outputOption);
scanCommand.Add(noUploadOption);
scanCommand.Add(serverUrlOption);
scanCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var output = parseResult.GetValue(outputOption);
var noUpload = parseResult.GetValue(noUploadOption);
var serverUrlArg = parseResult.GetValue(serverUrlOption);
if (!IsAdministrator())
Log.Warning("Not running as administrator — some data sources may be unavailable");
var sp = host.Services;
var deviceCollector = sp.GetRequiredService<DeviceCollector>();
var osCollector = sp.GetRequiredService<OsCollector>();
var wuCollector = sp.GetRequiredService<WindowsUpdateCollector>();
var rebootCollector = sp.GetRequiredService<PendingRebootCollector>();
var driverCollector = sp.GetRequiredService<DriverCollector>();
var cbsCollector = sp.GetRequiredService<CbsDismCollector>();
var policyCollector = sp.GetRequiredService<WindowsUpdatePolicyCollector>();
var hotfixCollector = sp.GetRequiredService<HotfixCollector>();
var eventCollector = sp.GetRequiredService<EventCollector>();
var uploader = sp.GetRequiredService<PayloadUploader>();
var credStore = sp.GetRequiredService<IDeviceCredentialStore>();
Log.Information("Starting PatchProbe scan on {Machine}", Environment.MachineName);
var (device, os, reboot, drivers, cbs, hotfixes, events, policy) = await CollectAllAsync(
deviceCollector, osCollector, rebootCollector, driverCollector, cbsCollector, hotfixCollector, eventCollector, policyCollector, ct);
var wuRaw = await wuCollector.CollectAsync(ct);
var wuInfo = new WindowsUpdateInfo
{
ApplicableUpdates = wuRaw.ApplicableUpdates,
History = wuRaw.History,
SearchError = wuRaw.SearchError,
Policy = policy,
};
var payload = new PatchProbePayload
{
Collector = new CollectorMeta
{
CollectedAt = DateTimeOffset.UtcNow,
MachineName = Environment.MachineName,
RanAsAdministrator = IsAdministrator(),
},
Device = device,
Os = os,
PendingReboot = reboot,
WindowsUpdate = wuInfo,
InstalledHotfixes = hotfixes,
CbsPackages = cbs,
Drivers = drivers,
RecentUpdateEvents = events,
};
if (!string.IsNullOrEmpty(output))
{
await PayloadSerializer.SerializeToFileAsync(payload, output, ct);
Log.Information("Payload written to {Path}", output);
}
else if (!noUpload)
{
// Resolve server URL: CLI arg → enrolled server URL → local write
var effectiveServerUrl = serverUrlArg
?? (credStore.IsEnrolled ? credStore.Load().ServerUrl : null);
if (effectiveServerUrl is null)
Log.Warning("No server URL configured — writing locally. Run 'PatchProbe.exe enroll' to register this device.");
await uploader.UploadAsync(payload, effectiveServerUrl, ct);
}
else
{
var json = PayloadSerializer.Serialize(payload);
Console.WriteLine(json);
}
Log.Information("Scan complete. Applicable updates: {Count}, Pending reboot: {Reboot}",
wuInfo.ApplicableUpdates.Count, reboot?.AnyPending);
});
// ── enroll ────────────────────────────────────────────────────────────
var enrollCommand = new Command("enroll", "Register this device with the PatchProbe server");
var enrollServerUrlOption = new Option<string?>("--server-url") { Description = "PatchProbe server URL (required)" };
var enrollmentKeyOption = new Option<string?>("--enrollment-key") { Description = "Enrollment key provided by your administrator (required)" };
var forceOption = new Option<bool>("--force") { Description = "Re-enroll even if this device is already enrolled" };
enrollCommand.Add(enrollServerUrlOption);
enrollCommand.Add(enrollmentKeyOption);
enrollCommand.Add(forceOption);
enrollCommand.SetAction(async (ParseResult parseResult, CancellationToken ct) =>
{
var serverUrl = parseResult.GetValue(enrollServerUrlOption);
var enrollmentKey = parseResult.GetValue(enrollmentKeyOption);
var force = parseResult.GetValue(forceOption);
if (string.IsNullOrEmpty(serverUrl) || string.IsNullOrEmpty(enrollmentKey))
{
Log.Error("--server-url and --enrollment-key are required");
return;
}
if (!IsAdministrator())
Log.Warning("Not running as administrator — credential storage to %ProgramData% may fail");
var sp = host.Services;
var credStore = sp.GetRequiredService<IDeviceCredentialStore>();
if (credStore.IsEnrolled && !force)
{
Log.Warning("Device is already enrolled. Use --force to re-enroll and generate a new keypair.");
return;
}
var enrollmentService = sp.GetRequiredService<EnrollmentService>();
await enrollmentService.EnrollAsync(serverUrl, enrollmentKey, ct);
});
// ── unenroll ──────────────────────────────────────────────────────────
var unenrollCommand = new Command("unenroll", "Remove stored device credentials from this endpoint");
unenrollCommand.SetAction((ParseResult _, CancellationToken _) =>
{
if (!IsAdministrator())
Log.Warning("Not running as administrator — credential removal may fail");
var credStore = host.Services.GetRequiredService<IDeviceCredentialStore>();
if (!credStore.IsEnrolled)
{
Log.Information("Device is not enrolled — nothing to remove");
return Task.CompletedTask;
}
credStore.Delete();
Log.Information("Device credentials removed from {Path}", DpapiCredentialStore.StorePath);
return Task.CompletedTask;
});
rootCommand.Add(scanCommand);
rootCommand.Add(enrollCommand);
rootCommand.Add(unenrollCommand);
var parseResult = rootCommand.Parse(args, new ParserConfiguration());
return await parseResult.InvokeAsync(new InvocationConfiguration());
}
catch (Exception ex)
{
Log.Fatal(ex, "Unhandled exception");
return 1;
}
finally
{
await Log.CloseAndFlushAsync();
}
static async Task<(DeviceInfo, OsInfo, PendingRebootInfo, List<DriverInfo>, List<CbsPackage>, List<InstalledHotfix>, List<UpdateEvent>, WindowsUpdatePolicy)>
CollectAllAsync(
DeviceCollector deviceCollector,
OsCollector osCollector,
PendingRebootCollector rebootCollector,
DriverCollector driverCollector,
CbsDismCollector cbsCollector,
HotfixCollector hotfixCollector,
EventCollector eventCollector,
WindowsUpdatePolicyCollector policyCollector,
CancellationToken ct)
{
var deviceTask = deviceCollector.CollectAsync(ct);
var osTask = osCollector.CollectAsync(ct);
var rebootTask = rebootCollector.CollectAsync(ct);
var driverTask = driverCollector.CollectAsync(ct);
var cbsTask = cbsCollector.CollectAsync(ct);
var hotfixTask = hotfixCollector.CollectAsync(ct);
var eventTask = eventCollector.CollectAsync(ct);
var policyTask = policyCollector.CollectAsync(ct);
await Task.WhenAll(deviceTask, osTask, rebootTask, driverTask, cbsTask, hotfixTask, eventTask, policyTask);
return (deviceTask.Result, osTask.Result, rebootTask.Result, driverTask.Result,
cbsTask.Result, hotfixTask.Result, eventTask.Result, policyTask.Result);
}
static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}