Initial Commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Built dashboard output (generated by npm run build)
|
||||
PatchProbe.Server/public/
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.vs/
|
||||
.claude/
|
||||
615
CLAUDE.md
Normal file
615
CLAUDE.md
Normal file
@@ -0,0 +1,615 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
dotnet build PatchProbe.Cli/PatchProbe.Cli.csproj
|
||||
|
||||
# Self-contained single-file win-x64 release
|
||||
dotnet publish PatchProbe.Cli/PatchProbe.Cli.csproj -c Release
|
||||
# Output: PatchProbe.Cli/bin/Release/net10.0-windows/win-x64/publish/PatchProbe.exe
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Collect evidence, write JSON to output/ directory
|
||||
PatchProbe.exe scan
|
||||
|
||||
# Write payload to a specific path
|
||||
PatchProbe.exe scan --output C:\temp\scan.json
|
||||
|
||||
# Collect but print JSON to stdout instead of uploading
|
||||
PatchProbe.exe scan --no-upload
|
||||
```
|
||||
|
||||
Note: must be run as administrator for full data access (WUA, event logs, WMI).
|
||||
|
||||
---
|
||||
|
||||
# Project Overview
|
||||
|
||||
I am building a Windows patch-management evidence collection and orchestration platform similar in concept to the patch-management component of major RMM tools.
|
||||
|
||||
The goal is NOT to replace Windows Update Agent (WUA), CBS, DISM, or the Windows driver-selection stack.
|
||||
|
||||
Instead, the platform should act as a centralized control plane that:
|
||||
|
||||
* collects patch-related evidence from endpoints
|
||||
* evaluates compliance
|
||||
* manages policy/rings/approvals
|
||||
* eventually orchestrates installations
|
||||
* reports status and failures
|
||||
|
||||
Windows itself remains authoritative for:
|
||||
|
||||
* update applicability
|
||||
* prerequisites
|
||||
* supersedence
|
||||
* CBS/component servicing logic
|
||||
* driver matching/ranking
|
||||
* installation success/failure
|
||||
|
||||
The collector should be:
|
||||
|
||||
* portable
|
||||
* standalone
|
||||
* self-contained
|
||||
* executable as administrator
|
||||
* non-persistent
|
||||
* non-installed
|
||||
* safe/read-only initially
|
||||
|
||||
The first milestone is NOT patch installation.
|
||||
|
||||
The first milestone is proving that a one-shot executable can collect equivalent patch-management evidence to what an RMM platform sees.
|
||||
|
||||
---
|
||||
|
||||
# Current State
|
||||
|
||||
I have already:
|
||||
|
||||
* created a new C# Console App
|
||||
* selected .NET 9
|
||||
* chosen a self-contained single-file EXE architecture
|
||||
|
||||
The collector executable should eventually be called:
|
||||
|
||||
```text
|
||||
PatchProbe.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Architecture Requirements
|
||||
|
||||
The application should use:
|
||||
|
||||
* C#
|
||||
* .NET 9
|
||||
* Generic Host
|
||||
* Dependency Injection
|
||||
* HttpClientFactory
|
||||
* Structured logging
|
||||
* Clean collector/service architecture
|
||||
|
||||
The project should remain a console application.
|
||||
|
||||
Do NOT use:
|
||||
|
||||
* WinForms
|
||||
* WPF
|
||||
* ASP.NET
|
||||
* Worker Service
|
||||
* MAUI
|
||||
|
||||
The collector flow is:
|
||||
|
||||
```text
|
||||
PatchProbe.exe
|
||||
→ collect evidence
|
||||
→ serialize to JSON
|
||||
→ upload to backend
|
||||
→ exit
|
||||
```
|
||||
|
||||
The collector should not:
|
||||
|
||||
* install updates
|
||||
* download updates
|
||||
* modify policy
|
||||
* create scheduled tasks
|
||||
* register services
|
||||
* persist locally beyond optional logs/cache
|
||||
|
||||
---
|
||||
|
||||
# Desired Solution Structure
|
||||
|
||||
The solution should be scaffolded approximately like this:
|
||||
|
||||
```text
|
||||
PatchProbe.sln
|
||||
│
|
||||
├── PatchProbe.Cli/
|
||||
│ ├── Program.cs
|
||||
│ ├── Collectors/
|
||||
│ ├── Models/
|
||||
│ ├── Services/
|
||||
│ └── Utils/
|
||||
│
|
||||
├── PatchProbe.Shared/
|
||||
│ ├── Models/
|
||||
│ ├── Contracts/
|
||||
│ └── Serialization/
|
||||
│
|
||||
└── PatchProbe.Engine.Contracts/
|
||||
├── ApiModels/
|
||||
└── Versioning/
|
||||
```
|
||||
|
||||
Even if some projects begin mostly empty.
|
||||
|
||||
---
|
||||
|
||||
# NuGet Packages To Use
|
||||
|
||||
Immediately add:
|
||||
|
||||
```bash
|
||||
dotnet add package System.Management
|
||||
dotnet add package Serilog
|
||||
dotnet add package Serilog.Sinks.Console
|
||||
dotnet add package Serilog.Sinks.File
|
||||
dotnet add package System.CommandLine
|
||||
dotnet add package Microsoft.Extensions.Hosting
|
||||
dotnet add package Microsoft.Extensions.DependencyInjection
|
||||
dotnet add package Microsoft.Extensions.Http
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Program.cs Architecture
|
||||
|
||||
Use Generic Host architecture.
|
||||
|
||||
Example direction:
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/patchprobe.log")
|
||||
.CreateLogger();
|
||||
|
||||
builder.Services.AddSingleton<DeviceCollector>();
|
||||
builder.Services.AddSingleton<WindowsUpdateCollector>();
|
||||
builder.Services.AddSingleton<PayloadUploader>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
var deviceCollector = host.Services.GetRequiredService<DeviceCollector>();
|
||||
var wuCollector = host.Services.GetRequiredService<WindowsUpdateCollector>();
|
||||
|
||||
var device = await deviceCollector.CollectAsync();
|
||||
var updates = await wuCollector.CollectAsync();
|
||||
|
||||
Console.WriteLine("Collection complete.");
|
||||
```
|
||||
|
||||
Maintain proper layering and separation of concerns.
|
||||
|
||||
---
|
||||
|
||||
# Core Philosophy
|
||||
|
||||
Treat collected data as immutable evidence.
|
||||
|
||||
Meaning:
|
||||
|
||||
```text
|
||||
PatchProbe scan = factual snapshot
|
||||
```
|
||||
|
||||
NOT:
|
||||
|
||||
* interpreted state
|
||||
* compliance judgment
|
||||
* install decisions
|
||||
|
||||
Those belong in the backend engine later.
|
||||
|
||||
The backend evaluates:
|
||||
|
||||
* policy
|
||||
* compliance
|
||||
* approval
|
||||
* orchestration
|
||||
* ring logic
|
||||
|
||||
The collector only gathers evidence.
|
||||
|
||||
---
|
||||
|
||||
# Windows Patch-Management Philosophy
|
||||
|
||||
The engine does NOT replace:
|
||||
|
||||
* Windows Update Agent
|
||||
* CBS
|
||||
* TrustedInstaller
|
||||
* DISM
|
||||
* Windows driver ranking
|
||||
|
||||
Instead:
|
||||
|
||||
```text
|
||||
The platform provides:
|
||||
- policy
|
||||
- scheduling
|
||||
- approvals
|
||||
- maintenance windows
|
||||
- telemetry
|
||||
- compliance reporting
|
||||
- failure classification
|
||||
- orchestration
|
||||
|
||||
Windows provides:
|
||||
- applicability
|
||||
- prerequisites
|
||||
- supersedence
|
||||
- servicing logic
|
||||
- driver selection
|
||||
- install success/failure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Required Initial Collectors
|
||||
|
||||
Implement collectors in this approximate order.
|
||||
|
||||
## 1. DeviceCollector
|
||||
|
||||
Collect:
|
||||
|
||||
* hostname
|
||||
* manufacturer
|
||||
* model
|
||||
* serial number
|
||||
* BIOS version
|
||||
* BIOS date
|
||||
* TPM presence/version
|
||||
* RAM
|
||||
* domain/workgroup
|
||||
* system type
|
||||
|
||||
Use:
|
||||
|
||||
* WMI/CIM
|
||||
* registry
|
||||
* environment info
|
||||
|
||||
---
|
||||
|
||||
## 2. OsCollector
|
||||
|
||||
Collect:
|
||||
|
||||
* ProductName
|
||||
* EditionID
|
||||
* DisplayVersion
|
||||
* ReleaseId
|
||||
* Build number
|
||||
* UBR
|
||||
* architecture
|
||||
* install date
|
||||
* last boot
|
||||
|
||||
Registry path:
|
||||
|
||||
```text
|
||||
HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. WindowsUpdateCollector
|
||||
|
||||
This is the core component.
|
||||
|
||||
Use WUA COM APIs.
|
||||
|
||||
Use:
|
||||
|
||||
* Microsoft.Update.Session
|
||||
* CreateUpdateSearcher()
|
||||
|
||||
Collect:
|
||||
|
||||
* applicable updates
|
||||
* update titles
|
||||
* KB IDs
|
||||
* categories
|
||||
* severity
|
||||
* reboot requirements
|
||||
* WUA result codes
|
||||
* update history
|
||||
* WU source/policy state
|
||||
|
||||
Search criteria:
|
||||
|
||||
```text
|
||||
IsInstalled=0 and IsHidden=0
|
||||
```
|
||||
|
||||
Use COM interop or dynamic COM access.
|
||||
|
||||
Example direction:
|
||||
|
||||
```csharp
|
||||
Type sessionType = Type.GetTypeFromProgID("Microsoft.Update.Session");
|
||||
dynamic session = Activator.CreateInstance(sessionType);
|
||||
dynamic searcher = session.CreateUpdateSearcher();
|
||||
|
||||
dynamic result = searcher.Search("IsInstalled=0 and IsHidden=0");
|
||||
```
|
||||
|
||||
The collector should ask Windows what is applicable rather than attempting to independently determine applicability.
|
||||
|
||||
---
|
||||
|
||||
# Important Patch-Management Concepts
|
||||
|
||||
Windows decides:
|
||||
|
||||
* whether an update is applicable
|
||||
* whether prerequisites are met
|
||||
* supersedence
|
||||
* CBS/component servicing logic
|
||||
* driver ranking/matching
|
||||
* installation success
|
||||
|
||||
The platform should observe and orchestrate, not replace these systems.
|
||||
|
||||
---
|
||||
|
||||
# 4. PendingRebootCollector
|
||||
|
||||
Collect pending reboot indicators from:
|
||||
|
||||
* CBS
|
||||
* Windows Update
|
||||
* Session Manager
|
||||
* Computer rename state
|
||||
|
||||
Registry locations include:
|
||||
|
||||
```text
|
||||
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending
|
||||
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired
|
||||
HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 5. DriverCollector
|
||||
|
||||
Collect:
|
||||
|
||||
* device name
|
||||
* driver version
|
||||
* manufacturer
|
||||
* INF name
|
||||
* hardware IDs
|
||||
* device class
|
||||
* driver date
|
||||
|
||||
Use:
|
||||
|
||||
* Win32_PnPSignedDriver
|
||||
* pnputil where useful
|
||||
|
||||
Important:
|
||||
Do NOT attempt to replace Windows driver ranking logic.
|
||||
|
||||
---
|
||||
|
||||
# 6. CBS / DISM Collector
|
||||
|
||||
Collect:
|
||||
|
||||
* package state
|
||||
* servicing state
|
||||
* DISM package output
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
dism /online /get-packages /format:table
|
||||
```
|
||||
|
||||
Treat CBS as the servicing authority.
|
||||
|
||||
---
|
||||
|
||||
# 7. Windows Update Policy Collector
|
||||
|
||||
Collect policy/configuration from:
|
||||
|
||||
* WSUS
|
||||
* WUfB
|
||||
* AU settings
|
||||
* deferrals
|
||||
|
||||
Registry paths include:
|
||||
|
||||
```text
|
||||
HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate
|
||||
HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU
|
||||
HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. Event Collector
|
||||
|
||||
Collect recent relevant events from:
|
||||
|
||||
* System
|
||||
* Microsoft-Windows-WindowsUpdateClient/Operational
|
||||
* Microsoft-Windows-UpdateOrchestrator/Operational
|
||||
|
||||
Focus on:
|
||||
|
||||
* failures
|
||||
* installs
|
||||
* servicing issues
|
||||
* reboot events
|
||||
|
||||
---
|
||||
|
||||
# Payload Design
|
||||
|
||||
The collector should produce a JSON payload approximately shaped like:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "0.1",
|
||||
"collector": {},
|
||||
"device": {},
|
||||
"os": {},
|
||||
"pendingReboot": {},
|
||||
"windowsUpdate": {
|
||||
"applicableUpdates": [],
|
||||
"history": [],
|
||||
"policy": {}
|
||||
},
|
||||
"installedHotfixes": [],
|
||||
"cbsPackages": [],
|
||||
"drivers": [],
|
||||
"recentUpdateEvents": []
|
||||
}
|
||||
```
|
||||
|
||||
This payload should be treated as immutable evidence.
|
||||
|
||||
---
|
||||
|
||||
# CLI Shape
|
||||
|
||||
The executable should eventually support:
|
||||
|
||||
```text
|
||||
PatchProbe.exe scan
|
||||
PatchProbe.exe upload
|
||||
PatchProbe.exe export
|
||||
PatchProbe.exe debug
|
||||
PatchProbe.exe validate
|
||||
```
|
||||
|
||||
Initially only implement:
|
||||
|
||||
```text
|
||||
PatchProbe.exe scan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Publishing Requirements
|
||||
|
||||
The application should be published as:
|
||||
|
||||
* self-contained
|
||||
* single-file
|
||||
* win-x64 executable
|
||||
|
||||
Example publish command:
|
||||
|
||||
```bash
|
||||
dotnet publish ^
|
||||
-c Release ^
|
||||
-r win-x64 ^
|
||||
--self-contained true ^
|
||||
-p:PublishSingleFile=true ^
|
||||
-p:EnableCompressionInSingleFile=true
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
* one EXE
|
||||
* no installed runtime dependency
|
||||
|
||||
---
|
||||
|
||||
# Important Design Constraints
|
||||
|
||||
Do NOT:
|
||||
|
||||
* tightly couple collection and analysis
|
||||
* tightly couple collection and orchestration
|
||||
* make compliance decisions in the collector
|
||||
|
||||
Maintain:
|
||||
|
||||
```text
|
||||
Collector
|
||||
→ evidence payload
|
||||
|
||||
Backend
|
||||
→ analysis/policy/compliance
|
||||
|
||||
Frontend
|
||||
→ visualization/reporting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Future Evolution Path
|
||||
|
||||
The architecture should naturally evolve into:
|
||||
|
||||
```text
|
||||
Phase 1:
|
||||
One-shot evidence collector
|
||||
|
||||
Phase 2:
|
||||
Scheduled execution
|
||||
|
||||
Phase 3:
|
||||
Patch orchestration
|
||||
|
||||
Phase 4:
|
||||
Remediation engine
|
||||
|
||||
Phase 5:
|
||||
Approval rings/policies
|
||||
|
||||
Phase 6:
|
||||
Full RMM-style patch-management platform
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Current Request
|
||||
|
||||
Based on all of the above:
|
||||
|
||||
* scaffold the initial project architecture
|
||||
* implement the first collectors
|
||||
* implement payload models
|
||||
* implement logging
|
||||
* implement WUA scanning
|
||||
* implement JSON serialization
|
||||
* implement upload abstraction
|
||||
* implement clean DI architecture
|
||||
* implement a production-quality starting structure suitable for future expansion
|
||||
6
PatchProbe.Cli/Auth/DeviceCredentials.cs
Normal file
6
PatchProbe.Cli/Auth/DeviceCredentials.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal sealed record DeviceCredentials(
|
||||
string DeviceId,
|
||||
string PrivateKeyPkcs8,
|
||||
string ServerUrl);
|
||||
55
PatchProbe.Cli/Auth/DpapiCredentialStore.cs
Normal file
55
PatchProbe.Cli/Auth/DpapiCredentialStore.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal sealed class DpapiCredentialStore : IDeviceCredentialStore
|
||||
{
|
||||
// %ProgramData%\PatchProbe\device.cred — machine-scoped, survives user changes
|
||||
internal static readonly string StorePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"PatchProbe", "device.cred");
|
||||
|
||||
public bool IsEnrolled => File.Exists(StorePath);
|
||||
|
||||
public DeviceCredentials Load()
|
||||
{
|
||||
var cipher = File.ReadAllBytes(StorePath);
|
||||
var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.LocalMachine);
|
||||
return JsonSerializer.Deserialize<DeviceCredentials>(Encoding.UTF8.GetString(plain))
|
||||
?? throw new InvalidOperationException("Credential file is corrupt or empty.");
|
||||
}
|
||||
|
||||
public void Save(DeviceCredentials credentials)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(StorePath)!;
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var plain = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(credentials));
|
||||
var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.LocalMachine);
|
||||
|
||||
File.WriteAllBytes(StorePath, cipher);
|
||||
RestrictToAdmins(StorePath);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
if (File.Exists(StorePath))
|
||||
File.Delete(StorePath);
|
||||
}
|
||||
|
||||
private static void RestrictToAdmins(string path)
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
var acl = fi.GetAccessControl();
|
||||
// Break inheritance; grant only SYSTEM and Administrators full control
|
||||
acl.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
acl.AddAccessRule(new FileSystemAccessRule(
|
||||
"SYSTEM", FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
acl.AddAccessRule(new FileSystemAccessRule(
|
||||
"Administrators", FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
fi.SetAccessControl(acl);
|
||||
}
|
||||
}
|
||||
31
PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs
Normal file
31
PatchProbe.Cli/Auth/EcdsaRequestAuthenticator.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal sealed class EcdsaRequestAuthenticator(IDeviceCredentialStore store) : IRequestAuthenticator
|
||||
{
|
||||
public Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default)
|
||||
{
|
||||
if (!store.IsEnrolled)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var creds = store.Load();
|
||||
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportPkcs8PrivateKey(Convert.FromBase64String(creds.PrivateKeyPkcs8), out _);
|
||||
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
||||
var bodyHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(requestBody)));
|
||||
|
||||
// Signed message: deviceId LF timestamp LF sha256(body)
|
||||
var message = Encoding.UTF8.GetBytes($"{creds.DeviceId}\n{timestamp}\n{bodyHash}");
|
||||
var signature = Convert.ToBase64String(ecdsa.SignData(message, HashAlgorithmName.SHA256));
|
||||
|
||||
request.Headers.Add("X-Device-Id", creds.DeviceId);
|
||||
request.Headers.Add("X-Timestamp", timestamp);
|
||||
request.Headers.Add("X-Signature", signature);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
9
PatchProbe.Cli/Auth/IDeviceCredentialStore.cs
Normal file
9
PatchProbe.Cli/Auth/IDeviceCredentialStore.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal interface IDeviceCredentialStore
|
||||
{
|
||||
bool IsEnrolled { get; }
|
||||
DeviceCredentials Load();
|
||||
void Save(DeviceCredentials credentials);
|
||||
void Delete();
|
||||
}
|
||||
7
PatchProbe.Cli/Auth/IRequestAuthenticator.cs
Normal file
7
PatchProbe.Cli/Auth/IRequestAuthenticator.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace PatchProbe.Cli.Auth;
|
||||
|
||||
internal interface IRequestAuthenticator
|
||||
{
|
||||
/// <summary>Adds authentication headers to an outgoing HTTP request.</summary>
|
||||
Task AuthenticateAsync(HttpRequestMessage request, string requestBody, CancellationToken ct = default);
|
||||
}
|
||||
84
PatchProbe.Cli/Collectors/CbsDismCollector.cs
Normal file
84
PatchProbe.Cli/Collectors/CbsDismCollector.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class CbsDismCollector(ILogger<CbsDismCollector> logger) : ICollector<List<CbsPackage>>
|
||||
{
|
||||
public async Task<List<CbsPackage>> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting CBS/DISM package state");
|
||||
|
||||
var packages = new List<CbsPackage>();
|
||||
|
||||
try
|
||||
{
|
||||
var output = await RunDismAsync(cancellationToken);
|
||||
packages = ParseDismOutput(output);
|
||||
logger.LogInformation("Collected {Count} CBS packages", packages.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "CBS/DISM collection failed");
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
private static async Task<string> RunDismAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dism.exe",
|
||||
Arguments = "/Online /Get-Packages /Format:Table",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start dism.exe");
|
||||
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static List<CbsPackage> ParseDismOutput(string output)
|
||||
{
|
||||
var packages = new List<CbsPackage>();
|
||||
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Skip header lines; data rows contain "Package Identity" columns separated by spaces/pipes
|
||||
bool inDataSection = false;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
if (trimmed.StartsWith("Package Identity", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inDataSection = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inDataSection || string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith('-'))
|
||||
continue;
|
||||
|
||||
// DISM table output uses multiple spaces as column separator
|
||||
var parts = System.Text.RegularExpressions.Regex.Split(trimmed, @"\s{2,}");
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
packages.Add(new CbsPackage
|
||||
{
|
||||
PackageIdentity = parts[0].Trim(),
|
||||
State = parts.Length > 1 ? parts[1].Trim() : null,
|
||||
ReleaseType = parts.Length > 2 ? parts[2].Trim() : null,
|
||||
InstallTime = parts.Length > 3 ? parts[3].Trim() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
}
|
||||
51
PatchProbe.Cli/Collectors/DeviceCollector.cs
Normal file
51
PatchProbe.Cli/Collectors/DeviceCollector.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Management;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class DeviceCollector(ILogger<DeviceCollector> logger) : ICollector<DeviceInfo>
|
||||
{
|
||||
public Task<DeviceInfo> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting device information");
|
||||
|
||||
var cs = QueryFirst("SELECT * FROM Win32_ComputerSystem");
|
||||
var bios = QueryFirst("SELECT * FROM Win32_BIOS");
|
||||
var tpm = QueryFirst("SELECT * FROM Win32_Tpm", @"root\CIMv2\Security\MicrosoftTpm");
|
||||
|
||||
var info = new DeviceInfo
|
||||
{
|
||||
Hostname = Environment.MachineName,
|
||||
Manufacturer = cs?["Manufacturer"]?.ToString()?.Trim(),
|
||||
Model = cs?["Model"]?.ToString()?.Trim(),
|
||||
SerialNumber = bios?["SerialNumber"]?.ToString()?.Trim(),
|
||||
BiosVersion = bios?["SMBIOSBIOSVersion"]?.ToString()?.Trim(),
|
||||
BiosDate = bios?["ReleaseDate"]?.ToString()?.Trim(),
|
||||
TpmPresent = tpm != null,
|
||||
TpmVersion = tpm?["SpecVersion"]?.ToString()?.Trim(),
|
||||
RamBytes = cs != null ? Convert.ToUInt64(cs["TotalPhysicalMemory"]) : 0,
|
||||
Domain = cs?["Domain"]?.ToString()?.Trim(),
|
||||
Workgroup = string.Equals(cs?["DomainRole"]?.ToString(), "0", StringComparison.Ordinal) ||
|
||||
string.Equals(cs?["DomainRole"]?.ToString(), "1", StringComparison.Ordinal)
|
||||
? cs?["Workgroup"]?.ToString()?.Trim() : null,
|
||||
SystemType = cs?["SystemType"]?.ToString()?.Trim(),
|
||||
};
|
||||
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
private static ManagementBaseObject? QueryFirst(string query, string scope = @"root\CIMv2")
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(new ManagementScope(scope), new ObjectQuery(query));
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementBaseObject obj in results)
|
||||
return obj;
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
60
PatchProbe.Cli/Collectors/DriverCollector.cs
Normal file
60
PatchProbe.Cli/Collectors/DriverCollector.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Management;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class DriverCollector(ILogger<DriverCollector> logger) : ICollector<List<DriverInfo>>
|
||||
{
|
||||
public Task<List<DriverInfo>> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting driver information via Win32_PnPSignedDriver");
|
||||
|
||||
var drivers = new List<DriverInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"root\CIMv2"),
|
||||
new ObjectQuery("SELECT * FROM Win32_PnPSignedDriver WHERE DeviceName IS NOT NULL"));
|
||||
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementBaseObject obj in results)
|
||||
{
|
||||
DateTimeOffset? driverDate = null;
|
||||
var dateRaw = obj["DriverDate"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(dateRaw))
|
||||
{
|
||||
try
|
||||
{
|
||||
// WMI datetime format: yyyymmddHHmmss.ffffff+UTC — convert via UTC to avoid local-offset mismatch
|
||||
var dt = ManagementDateTimeConverter.ToDateTime(dateRaw);
|
||||
driverDate = new DateTimeOffset(dt.ToUniversalTime(), TimeSpan.Zero);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
drivers.Add(new DriverInfo
|
||||
{
|
||||
DeviceName = obj["DeviceName"]?.ToString()?.Trim(),
|
||||
DriverVersion = obj["DriverVersion"]?.ToString()?.Trim(),
|
||||
Manufacturer = obj["Manufacturer"]?.ToString()?.Trim(),
|
||||
InfName = obj["InfName"]?.ToString()?.Trim(),
|
||||
HardwareId = obj["HardWareID"]?.ToString()?.Trim(),
|
||||
DeviceClass = obj["DeviceClass"]?.ToString()?.Trim(),
|
||||
DriverDate = driverDate,
|
||||
IsSigned = string.Equals(obj["IsSigned"]?.ToString(), "True", StringComparison.OrdinalIgnoreCase),
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("Collected {Count} drivers", drivers.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Driver collection failed");
|
||||
}
|
||||
|
||||
return Task.FromResult(drivers);
|
||||
}
|
||||
}
|
||||
78
PatchProbe.Cli/Collectors/EventCollector.cs
Normal file
78
PatchProbe.Cli/Collectors/EventCollector.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class EventCollector(ILogger<EventCollector> logger) : ICollector<List<UpdateEvent>>
|
||||
{
|
||||
private static readonly (string LogName, string? Provider)[] Sources =
|
||||
[
|
||||
("System", null),
|
||||
("Microsoft-Windows-WindowsUpdateClient/Operational", "Microsoft-Windows-WindowsUpdateClient"),
|
||||
("Microsoft-Windows-UpdateOrchestrator/Operational", "Microsoft-Windows-UpdateOrchestrator"),
|
||||
];
|
||||
|
||||
private const int LookbackHours = 72;
|
||||
private const int MaxEventsPerSource = 50;
|
||||
|
||||
public Task<List<UpdateEvent>> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting recent Windows Update events (last {Hours}h)", LookbackHours);
|
||||
|
||||
var events = new List<UpdateEvent>();
|
||||
var since = DateTime.UtcNow.AddHours(-LookbackHours);
|
||||
|
||||
foreach (var (logName, provider) in Sources)
|
||||
{
|
||||
try
|
||||
{
|
||||
events.AddRange(ReadEvents(logName, provider, since));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Could not read event log: {LogName}", logName);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Collected {Count} update events", events.Count);
|
||||
return Task.FromResult(events);
|
||||
}
|
||||
|
||||
private static IEnumerable<UpdateEvent> ReadEvents(string logName, string? provider, DateTime since)
|
||||
{
|
||||
var providerFilter = provider != null ? $" and Provider[@Name='{provider}']" : string.Empty;
|
||||
var query = new EventLogQuery(
|
||||
logName,
|
||||
PathType.LogName,
|
||||
$"*[System[(Level<=3){providerFilter} and TimeCreated[@SystemTime>='{since:yyyy-MM-ddTHH:mm:ss.000Z}']]]");
|
||||
|
||||
using var reader = new EventLogReader(query);
|
||||
int count = 0;
|
||||
while (reader.ReadEvent() is EventRecord record && count < MaxEventsPerSource)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
yield return new UpdateEvent
|
||||
{
|
||||
EventId = record.Id,
|
||||
Source = record.ProviderName,
|
||||
LogName = record.LogName,
|
||||
Level = record.LevelDisplayName,
|
||||
TimeCreated = record.TimeCreated.HasValue
|
||||
? new DateTimeOffset(record.TimeCreated.Value, TimeSpan.Zero)
|
||||
: null,
|
||||
Message = TryFormatMessage(record),
|
||||
};
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryFormatMessage(EventRecord record)
|
||||
{
|
||||
try { return record.FormatDescription(); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
48
PatchProbe.Cli/Collectors/HotfixCollector.cs
Normal file
48
PatchProbe.Cli/Collectors/HotfixCollector.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Management;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class HotfixCollector(ILogger<HotfixCollector> logger) : ICollector<List<InstalledHotfix>>
|
||||
{
|
||||
public Task<List<InstalledHotfix>> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting installed hotfixes via Win32_QuickFixEngineering");
|
||||
|
||||
var hotfixes = new List<InstalledHotfix>();
|
||||
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"root\CIMv2"),
|
||||
new ObjectQuery("SELECT * FROM Win32_QuickFixEngineering"));
|
||||
|
||||
using var results = searcher.Get();
|
||||
foreach (ManagementBaseObject obj in results)
|
||||
{
|
||||
DateTimeOffset? installedOn = null;
|
||||
var dateRaw = obj["InstalledOn"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(dateRaw) && DateTime.TryParse(dateRaw, out var dt))
|
||||
installedOn = new DateTimeOffset(dt, TimeSpan.Zero);
|
||||
|
||||
hotfixes.Add(new InstalledHotfix
|
||||
{
|
||||
HotFixId = obj["HotFixID"]?.ToString()?.Trim(),
|
||||
Description = obj["Description"]?.ToString()?.Trim(),
|
||||
InstalledBy = obj["InstalledBy"]?.ToString()?.Trim(),
|
||||
InstalledOn = installedOn,
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("Collected {Count} installed hotfixes", hotfixes.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Hotfix collection failed");
|
||||
}
|
||||
|
||||
return Task.FromResult(hotfixes);
|
||||
}
|
||||
}
|
||||
48
PatchProbe.Cli/Collectors/OsCollector.cs
Normal file
48
PatchProbe.Cli/Collectors/OsCollector.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class OsCollector(ILogger<OsCollector> logger) : ICollector<OsInfo>
|
||||
{
|
||||
private const string CurrentVersionKey = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion";
|
||||
|
||||
public Task<OsInfo> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting OS information");
|
||||
|
||||
using var key = Registry.LocalMachine.OpenSubKey(CurrentVersionKey);
|
||||
|
||||
var installDateRaw = key?.GetValue("InstallDate");
|
||||
DateTimeOffset? installDate = installDateRaw is int installDateInt
|
||||
? DateTimeOffset.FromUnixTimeSeconds(installDateInt)
|
||||
: null;
|
||||
|
||||
var info = new OsInfo
|
||||
{
|
||||
ProductName = key?.GetValue("ProductName")?.ToString(),
|
||||
EditionId = key?.GetValue("EditionID")?.ToString(),
|
||||
DisplayVersion = key?.GetValue("DisplayVersion")?.ToString(),
|
||||
ReleaseId = key?.GetValue("ReleaseId")?.ToString(),
|
||||
BuildNumber = key?.GetValue("CurrentBuildNumber")?.ToString(),
|
||||
Ubr = key?.GetValue("UBR") is int ubr ? ubr : 0,
|
||||
Architecture = Environment.Is64BitOperatingSystem ? "x64" : "x86",
|
||||
InstallDate = installDate,
|
||||
LastBoot = GetLastBoot(),
|
||||
};
|
||||
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetLastBoot()
|
||||
{
|
||||
try
|
||||
{
|
||||
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
|
||||
return DateTimeOffset.UtcNow - uptime;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
64
PatchProbe.Cli/Collectors/PendingRebootCollector.cs
Normal file
64
PatchProbe.Cli/Collectors/PendingRebootCollector.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class PendingRebootCollector(ILogger<PendingRebootCollector> logger) : ICollector<PendingRebootInfo>
|
||||
{
|
||||
public Task<PendingRebootInfo> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting pending reboot indicators");
|
||||
|
||||
var info = new PendingRebootInfo
|
||||
{
|
||||
CbsRebootPending = KeyExists(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending")
|
||||
|| KeyExists(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootInProgress"),
|
||||
WindowsUpdateRebootRequired = KeyExists(@"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"),
|
||||
SessionManagerRebootRequired = HasPendingFileRenameOperations(),
|
||||
ComputerRenameRequired = HasPendingComputerRename(),
|
||||
};
|
||||
|
||||
logger.LogInformation("Pending reboot: {AnyPending} (CBS={Cbs}, WU={Wu}, SessionMgr={Sm}, Rename={Rename})",
|
||||
info.AnyPending, info.CbsRebootPending, info.WindowsUpdateRebootRequired,
|
||||
info.SessionManagerRebootRequired, info.ComputerRenameRequired);
|
||||
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
private static bool KeyExists(string subKeyPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(subKeyPath);
|
||||
return key != null;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static bool HasPendingFileRenameOperations()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\Session Manager");
|
||||
var value = key?.GetValue("PendingFileRenameOperations");
|
||||
if (value is string[] arr) return arr.Length > 0;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasPendingComputerRename()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName");
|
||||
using var pendingKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName");
|
||||
var active = key?.GetValue("ComputerName")?.ToString();
|
||||
var pending = pendingKey?.GetValue("ComputerName")?.ToString();
|
||||
return !string.Equals(active, pending, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
145
PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs
Normal file
145
PatchProbe.Cli/Collectors/WindowsUpdateCollector.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class WindowsUpdateCollector(ILogger<WindowsUpdateCollector> logger) : ICollector<WindowsUpdateInfo>
|
||||
{
|
||||
public Task<WindowsUpdateInfo> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting Windows Update information via WUA COM");
|
||||
|
||||
var applicable = new List<ApplicableUpdate>();
|
||||
var history = new List<UpdateHistoryEntry>();
|
||||
string? searchError = null;
|
||||
|
||||
try
|
||||
{
|
||||
var sessionType = Type.GetTypeFromProgID("Microsoft.Update.Session")
|
||||
?? throw new InvalidOperationException("Microsoft.Update.Session ProgID not found");
|
||||
dynamic session = Activator.CreateInstance(sessionType)!;
|
||||
dynamic searcher = session.CreateUpdateSearcher();
|
||||
|
||||
logger.LogInformation("Searching for applicable updates (IsInstalled=0 and IsHidden=0)");
|
||||
dynamic result = searcher.Search("IsInstalled=0 and IsHidden=0");
|
||||
|
||||
for (int i = 0; i < result.Updates.Count; i++)
|
||||
{
|
||||
dynamic u = result.Updates.Item(i);
|
||||
string kbId = ExtractKbId(u);
|
||||
string category = ExtractFirstCategory(u);
|
||||
|
||||
applicable.Add(new ApplicableUpdate
|
||||
{
|
||||
UpdateId = TryGet<string>(() => u.Identity.UpdateID),
|
||||
Title = TryGet<string>(() => u.Title),
|
||||
KbArticleId = kbId,
|
||||
Category = category,
|
||||
Severity = TryGet<string>(() => u.MsrcSeverity),
|
||||
RebootRequired = TryGet<bool>(() => u.RebootRequired),
|
||||
IsDownloaded = TryGet<bool>(() => u.IsDownloaded),
|
||||
Description = TryGet<string>(() => u.Description),
|
||||
SupportUrl = TryGet<string>(() => u.SupportUrl),
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("Found {Count} applicable updates", applicable.Count);
|
||||
|
||||
int totalHistoryCount = searcher.GetTotalHistoryCount();
|
||||
int historyLimit = Math.Min(totalHistoryCount, 100);
|
||||
|
||||
if (historyLimit > 0)
|
||||
{
|
||||
dynamic historyEntries = searcher.QueryHistory(0, historyLimit);
|
||||
for (int i = 0; i < historyEntries.Count; i++)
|
||||
{
|
||||
dynamic e = historyEntries.Item(i);
|
||||
history.Add(new UpdateHistoryEntry
|
||||
{
|
||||
UpdateId = TryGet<string>(() => e.UpdateIdentity.UpdateID),
|
||||
Title = TryGet<string>(() => e.Title),
|
||||
KbArticleId = ExtractKbIdFromTitle(TryGet<string>(() => e.Title)),
|
||||
ResultCode = TryGet<int>(() => (int)e.ResultCode),
|
||||
HResult = TryGet<int>(() => e.HResult),
|
||||
Date = TryGetDate(() => e.Date),
|
||||
Operation = TryGet<int>(() => (int)e.Operation) switch
|
||||
{
|
||||
1 => "Installation",
|
||||
2 => "Uninstallation",
|
||||
_ => "Unknown"
|
||||
},
|
||||
ServerSelection = TryGet<int>(() => (int)e.ServerSelection) switch
|
||||
{
|
||||
0 => "Default",
|
||||
1 => "ManagedServer",
|
||||
2 => "WindowsUpdate",
|
||||
3 => "Others",
|
||||
_ => "Unknown"
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "WUA search failed");
|
||||
searchError = ex.Message;
|
||||
}
|
||||
|
||||
return Task.FromResult(new WindowsUpdateInfo
|
||||
{
|
||||
ApplicableUpdates = applicable,
|
||||
History = history,
|
||||
SearchError = searchError,
|
||||
});
|
||||
}
|
||||
|
||||
private static string ExtractKbId(dynamic update)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (update.KBArticleIDs.Count > 0)
|
||||
return "KB" + update.KBArticleIDs.Item(0);
|
||||
}
|
||||
catch { /* fall through to title parse */ }
|
||||
return ExtractKbIdFromTitle(TryGet<string>(() => update.Title));
|
||||
}
|
||||
|
||||
private static string ExtractFirstCategory(dynamic update)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (update.Categories.Count > 0)
|
||||
return update.Categories.Item(0).Name;
|
||||
}
|
||||
catch { }
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string ExtractKbIdFromTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title)) return string.Empty;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(title, @"KB\d+", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Value.ToUpperInvariant() : string.Empty;
|
||||
}
|
||||
|
||||
private static T TryGet<T>(Func<T> getter)
|
||||
{
|
||||
try { return getter(); }
|
||||
catch { return default!; }
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryGetDate(Func<object> getter)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = getter();
|
||||
if (raw is DateTime dt) return new DateTimeOffset(dt, TimeSpan.Zero);
|
||||
if (raw is DateTimeOffset dto) return dto;
|
||||
if (DateTime.TryParse(raw?.ToString(), out var parsed)) return new DateTimeOffset(parsed, TimeSpan.Zero);
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
49
PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs
Normal file
49
PatchProbe.Cli/Collectors/WindowsUpdatePolicyCollector.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Cli.Collectors;
|
||||
|
||||
public sealed class WindowsUpdatePolicyCollector(ILogger<WindowsUpdatePolicyCollector> logger) : ICollector<WindowsUpdatePolicy>
|
||||
{
|
||||
private const string WuPolicyKey = @"SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate";
|
||||
private const string AuPolicyKey = @"SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU";
|
||||
private const string WuUxKey = @"SOFTWARE\Microsoft\WindowsUpdate\UX\Settings";
|
||||
|
||||
public Task<WindowsUpdatePolicy> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation("Collecting Windows Update policy");
|
||||
|
||||
using var wuKey = Registry.LocalMachine.OpenSubKey(WuPolicyKey);
|
||||
using var auKey = Registry.LocalMachine.OpenSubKey(AuPolicyKey);
|
||||
using var uxKey = Registry.LocalMachine.OpenSubKey(WuUxKey);
|
||||
|
||||
var wsusServer = wuKey?.GetValue("WUServer")?.ToString();
|
||||
var policy = new WindowsUpdatePolicy
|
||||
{
|
||||
WsusConfigured = !string.IsNullOrEmpty(wsusServer),
|
||||
WsusServer = wsusServer,
|
||||
WsusStatusServer = wuKey?.GetValue("WUStatusServer")?.ToString(),
|
||||
WufbConfigured = wuKey?.GetValue("ManagePreviewBuilds") != null
|
||||
|| uxKey?.GetValue("BranchReadinessLevel") != null,
|
||||
DeferFeatureUpdatesDays = GetInt(wuKey, "DeferFeatureUpdatesPeriodInDays")
|
||||
?? GetInt(uxKey, "DeferFeatureUpdatesPeriodInDays"),
|
||||
DeferQualityUpdatesDays = GetInt(wuKey, "DeferQualityUpdatesPeriodInDays")
|
||||
?? GetInt(uxKey, "DeferQualityUpdatesPeriodInDays"),
|
||||
AutoUpdateEnabled = GetInt(auKey, "NoAutoUpdate") != 1,
|
||||
AuOptions = GetInt(auKey, "AUOptions"),
|
||||
TargetGroupEnabled = GetInt(wuKey, "TargetGroupEnabled") == 1,
|
||||
TargetGroup = wuKey?.GetValue("TargetGroup")?.ToString(),
|
||||
BranchReadinessLevel = uxKey?.GetValue("BranchReadinessLevel")?.ToString(),
|
||||
};
|
||||
|
||||
return Task.FromResult(policy);
|
||||
}
|
||||
|
||||
private static int? GetInt(RegistryKey? key, string name)
|
||||
{
|
||||
if (key?.GetValue(name) is int v) return v;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
36
PatchProbe.Cli/PatchProbe.Cli.csproj
Normal file
36
PatchProbe.Cli/PatchProbe.Cli.csproj
Normal file
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PatchProbe.Shared\PatchProbe.Shared.csproj" />
|
||||
<ProjectReference Include="..\PatchProbe.Engine.Contracts\PatchProbe.Engine.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.8" />
|
||||
<PackageReference Include="System.Management" Version="10.0.8" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>PatchProbe</AssemblyName>
|
||||
<RootNamespace>PatchProbe.Cli</RootNamespace>
|
||||
<!-- Publish defaults for win-x64 self-contained single-file EXE -->
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
244
PatchProbe.Cli/Program.cs
Normal file
244
PatchProbe.Cli/Program.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
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);
|
||||
}
|
||||
54
PatchProbe.Cli/Services/EnrollmentService.cs
Normal file
54
PatchProbe.Cli/Services/EnrollmentService.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
using PatchProbe.Cli.Auth;
|
||||
using PatchProbe.Engine.Contracts.ApiModels;
|
||||
|
||||
namespace PatchProbe.Cli.Services;
|
||||
|
||||
internal sealed class EnrollmentService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IDeviceCredentialStore credentialStore,
|
||||
ILogger<EnrollmentService> logger)
|
||||
{
|
||||
public async Task EnrollAsync(string serverUrl, string enrollmentKey, CancellationToken ct = default)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeySpki = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo());
|
||||
var privateKeyPkcs8 = Convert.ToBase64String(ecdsa.ExportPkcs8PrivateKey());
|
||||
|
||||
var requestBody = new EnrollmentRequest(
|
||||
EnrollmentKey: enrollmentKey,
|
||||
MachineName: Environment.MachineName,
|
||||
DeviceFingerprint: GetMachineFingerprint(),
|
||||
PublicKeySpki: publicKeySpki);
|
||||
|
||||
var http = httpClientFactory.CreateClient();
|
||||
var url = $"{serverUrl.TrimEnd('/')}/api/enrollments";
|
||||
|
||||
logger.LogInformation("Enrolling device with server at {Url}", url);
|
||||
|
||||
var response = await http.PostAsJsonAsync(url, requestBody, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<EnrollmentResponse>(cancellationToken: ct)
|
||||
?? throw new InvalidOperationException("Server returned an empty enrollment response.");
|
||||
|
||||
credentialStore.Save(new DeviceCredentials(
|
||||
DeviceId: result.DeviceId,
|
||||
PrivateKeyPkcs8: privateKeyPkcs8,
|
||||
ServerUrl: serverUrl));
|
||||
|
||||
logger.LogInformation("Enrollment complete — Device ID: {DeviceId}", result.DeviceId);
|
||||
}
|
||||
|
||||
private static string GetMachineFingerprint()
|
||||
{
|
||||
// MachineGuid is a stable, per-install GUID set by Windows Setup.
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography");
|
||||
var guid = key?.GetValue("MachineGuid")?.ToString() ?? Environment.MachineName;
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(guid))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
60
PatchProbe.Cli/Services/PayloadUploader.cs
Normal file
60
PatchProbe.Cli/Services/PayloadUploader.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PatchProbe.Cli.Auth;
|
||||
using PatchProbe.Shared.Contracts;
|
||||
using PatchProbe.Shared.Models;
|
||||
using PatchProbe.Shared.Serialization;
|
||||
|
||||
namespace PatchProbe.Cli.Services;
|
||||
|
||||
internal sealed class PayloadUploader(
|
||||
ILogger<PayloadUploader> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IRequestAuthenticator authenticator) : IPayloadUploader
|
||||
{
|
||||
public async Task UploadAsync(PatchProbePayload payload, string? serverUrl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(serverUrl))
|
||||
{
|
||||
await PostToServerAsync(payload, serverUrl, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteLocalAsync(payload, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Task IPayloadUploader.UploadAsync(PatchProbePayload payload, CancellationToken cancellationToken) =>
|
||||
UploadAsync(payload, null, cancellationToken);
|
||||
|
||||
private async Task PostToServerAsync(PatchProbePayload payload, string serverUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = serverUrl.TrimEnd('/') + "/api/scans";
|
||||
logger.LogInformation("Uploading scan to {Url}", url);
|
||||
|
||||
var json = PayloadSerializer.Serialize(payload);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
await authenticator.AuthenticateAsync(request, json, cancellationToken);
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
var response = await client.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
logger.LogInformation("Scan uploaded successfully (HTTP {Status})", (int)response.StatusCode);
|
||||
}
|
||||
|
||||
private async Task WriteLocalAsync(PatchProbePayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var outputDir = Path.Combine(AppContext.BaseDirectory, "output");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var fileName = $"patchprobe_{payload.Collector.CollectedAt:yyyyMMdd_HHmmss}_{payload.Collector.MachineName}.json";
|
||||
var filePath = Path.Combine(outputDir, fileName);
|
||||
|
||||
await PayloadSerializer.SerializeToFileAsync(payload, filePath, cancellationToken);
|
||||
logger.LogInformation("Payload written to {FilePath}", filePath);
|
||||
}
|
||||
}
|
||||
12
PatchProbe.Dashboard/index.html
Normal file
12
PatchProbe.Dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PatchProbe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2543
PatchProbe.Dashboard/package-lock.json
generated
Normal file
2543
PatchProbe.Dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
PatchProbe.Dashboard/package.json
Normal file
24
PatchProbe.Dashboard/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "patchprobe-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.0.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
44
PatchProbe.Dashboard/src/App.jsx
Normal file
44
PatchProbe.Dashboard/src/App.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import Devices from './pages/Devices.jsx';
|
||||
import DeviceDetail from './pages/DeviceDetail.jsx';
|
||||
import Scans from './pages/Scans.jsx';
|
||||
import ScanDetail from './pages/ScanDetail.jsx';
|
||||
import Admin from './pages/Admin.jsx';
|
||||
|
||||
function PrivateRoute({ children }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
if (isLoading) return <div className="flex h-screen items-center justify-center text-zinc-400">Loading…</div>;
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="devices" element={<Devices />} />
|
||||
<Route path="devices/:id" element={<DeviceDetail />} />
|
||||
<Route path="scans" element={<Scans />} />
|
||||
<Route path="scans/:id" element={<ScanDetail />} />
|
||||
<Route path="admin" element={<Admin />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
62
PatchProbe.Dashboard/src/components/JsonViewer.jsx
Normal file
62
PatchProbe.Dashboard/src/components/JsonViewer.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function JsonNode({ data, depth = 0 }) {
|
||||
const [collapsed, setCollapsed] = useState(depth > 2);
|
||||
|
||||
if (data === null) return <span className="text-red-400">null</span>;
|
||||
if (data === true) return <span className="text-blue-400">true</span>;
|
||||
if (data === false) return <span className="text-blue-400">false</span>;
|
||||
if (typeof data === 'number') return <span className="text-amber-400">{data}</span>;
|
||||
if (typeof data === 'string') return <span className="text-green-400">"{data}"</span>;
|
||||
|
||||
const isArray = Array.isArray(data);
|
||||
const entries = isArray ? data.map((v, i) => [i, v]) : Object.entries(data);
|
||||
const open = isArray ? '[' : '{';
|
||||
const close = isArray ? ']' : '}';
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-zinc-400">{open}{close}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="text-zinc-500 hover:text-zinc-300 mr-1 font-mono text-xs"
|
||||
>
|
||||
{collapsed ? '▶' : '▼'}
|
||||
</button>
|
||||
<span className="text-zinc-400">{open}</span>
|
||||
{collapsed ? (
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs mx-1"
|
||||
>
|
||||
{entries.length} {isArray ? 'items' : 'keys'} …
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ paddingLeft: '1.25rem' }}>
|
||||
{entries.map(([key, val], idx) => (
|
||||
<div key={key}>
|
||||
{!isArray && (
|
||||
<span className="text-zinc-300">"{key}"</span>
|
||||
)}
|
||||
{!isArray && <span className="text-zinc-500">: </span>}
|
||||
<JsonNode data={val} depth={depth + 1} />
|
||||
{idx < entries.length - 1 && <span className="text-zinc-600">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && <span className="text-zinc-400">{close}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JsonViewer({ data }) {
|
||||
return (
|
||||
<pre className="font-mono text-sm leading-relaxed whitespace-pre-wrap break-all">
|
||||
<JsonNode data={data} depth={0} />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
64
PatchProbe.Dashboard/src/components/Layout.jsx
Normal file
64
PatchProbe.Dashboard/src/components/Layout.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth.jsx';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard', icon: '▦' },
|
||||
{ to: '/devices', label: 'Devices', icon: '⬡' },
|
||||
{ to: '/scans', label: 'Scans', icon: '≡' },
|
||||
{ to: '/admin', label: 'Admin', icon: '⚙' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-52 flex flex-col flex-shrink-0 bg-zinc-900 border-r border-zinc-800">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-zinc-800">
|
||||
<span className="text-lg font-semibold tracking-tight text-zinc-100">PatchProbe</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base leading-none">{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User + logout */}
|
||||
<div className="px-4 py-4 border-t border-zinc-800">
|
||||
<p className="text-xs text-zinc-500 truncate mb-2">{user?.displayName ?? user?.username}</p>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto px-8 py-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
PatchProbe.Dashboard/src/components/StatusBadge.jsx
Normal file
14
PatchProbe.Dashboard/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function StatusBadge({ variant, children }) {
|
||||
const styles = {
|
||||
green: 'bg-green-950 text-green-400 border-green-800',
|
||||
red: 'bg-red-950 text-red-400 border-red-800',
|
||||
amber: 'bg-amber-950 text-amber-400 border-amber-800',
|
||||
indigo: 'bg-indigo-950 text-indigo-400 border-indigo-800',
|
||||
zinc: 'bg-zinc-800 text-zinc-400 border-zinc-700',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${styles[variant] ?? styles.zinc}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
36
PatchProbe.Dashboard/src/hooks/useAuth.jsx
Normal file
36
PatchProbe.Dashboard/src/hooks/useAuth.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authApi } from '../lib/api.js';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, isLoading, error } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: authApi.me,
|
||||
retry: false,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
window.location.href = '/login';
|
||||
},
|
||||
});
|
||||
|
||||
const isAuthenticated = !!user && !error;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, isAuthenticated, logout: logoutMutation.mutate }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
8
PatchProbe.Dashboard/src/index.css
Normal file
8
PatchProbe.Dashboard/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: theme(--color-zinc-950);
|
||||
color: theme(--color-zinc-100);
|
||||
}
|
||||
}
|
||||
56
PatchProbe.Dashboard/src/lib/api.js
Normal file
56
PatchProbe.Dashboard/src/lib/api.js
Normal file
@@ -0,0 +1,56 @@
|
||||
class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = { method, credentials: 'include', headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (!res.ok) throw new ApiError(data.error ?? 'Request failed', res.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, body),
|
||||
delete: (path) => request('DELETE', path),
|
||||
};
|
||||
|
||||
// Typed helpers for each resource
|
||||
export const authApi = {
|
||||
status: () => api.get('/api/auth/status'),
|
||||
me: () => api.get('/api/auth/me'),
|
||||
logout: () => api.post('/api/auth/logout'),
|
||||
registerBegin: (body) => api.post('/api/auth/register/begin', body),
|
||||
registerFinish: (body) => api.post('/api/auth/register/finish', body),
|
||||
loginBegin: () => api.post('/api/auth/login/begin'),
|
||||
loginFinish: (body) => api.post('/api/auth/login/finish', body),
|
||||
passkeys: () => api.get('/api/auth/passkeys'),
|
||||
deletePasskey: (id) => api.delete(`/api/auth/passkeys/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get('/api/admin/devices'),
|
||||
revoke: (id) => api.delete(`/api/admin/devices/${id}`),
|
||||
scans: (id) => api.get(`/api/admin/devices/${id}/scans`),
|
||||
};
|
||||
|
||||
export const scansApi = {
|
||||
list: () => api.get('/api/scans'),
|
||||
get: (id) => api.get(`/api/scans/${id}`),
|
||||
remove: (id) => api.delete(`/api/scans/${id}`),
|
||||
};
|
||||
|
||||
export const tokensApi = {
|
||||
list: () => api.get('/api/admin/tokens'),
|
||||
create: (body) => api.post('/api/admin/tokens', body),
|
||||
revoke: (tok) => api.delete(`/api/admin/tokens/${tok}`),
|
||||
};
|
||||
22
PatchProbe.Dashboard/src/main.jsx
Normal file
22
PatchProbe.Dashboard/src/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
281
PatchProbe.Dashboard/src/pages/Admin.jsx
Normal file
281
PatchProbe.Dashboard/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { tokensApi, authApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrollment tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TokensSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState({ label: '', expiresInDays: '', maxUses: '' });
|
||||
const [created, setCreated] = useState(null);
|
||||
|
||||
const { data: tokens = [] } = useQuery({ queryKey: ['tokens'], queryFn: tokensApi.list });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body) => tokensApi.create(body),
|
||||
onSuccess: (data) => {
|
||||
setCreated(data.token);
|
||||
setForm({ label: '', expiresInDays: '', maxUses: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['tokens'] });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: tokensApi.revoke,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tokens'] }),
|
||||
});
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
const body = { label: form.label };
|
||||
if (form.expiresInDays) body.expiresInDays = parseInt(form.expiresInDays, 10);
|
||||
if (form.maxUses) body.maxUses = parseInt(form.maxUses, 10);
|
||||
createMutation.mutate(body);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Enrollment Tokens</h2>
|
||||
|
||||
{/* Created token — show once */}
|
||||
{created && (
|
||||
<div className="bg-indigo-950 border border-indigo-800 rounded-xl p-4">
|
||||
<p className="text-xs text-indigo-400 mb-1 font-medium">Token created — copy now, it won't be shown again:</p>
|
||||
<code className="text-sm text-indigo-200 break-all">{created}</code>
|
||||
<button onClick={() => setCreated(null)} className="mt-2 block text-xs text-indigo-500 hover:text-indigo-400">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
<form onSubmit={submit} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<p className="text-sm font-medium text-zinc-300 mb-4">Create token</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
<div className="col-span-3 md:col-span-1">
|
||||
<label className="block text-xs text-zinc-500 mb-1">Label *</label>
|
||||
<input
|
||||
required value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||||
placeholder="lab-devices"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Expires in (days)</label>
|
||||
<input
|
||||
type="number" min="1" value={form.expiresInDays} onChange={e => setForm(f => ({ ...f, expiresInDays: e.target.value }))}
|
||||
placeholder="never"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Max uses</label>
|
||||
<input
|
||||
type="number" min="1" value={form.maxUses} onChange={e => setForm(f => ({ ...f, maxUses: e.target.value }))}
|
||||
placeholder="unlimited"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit" disabled={createMutation.isPending}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Token list */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Token</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Label</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Expires</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Uses</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{tokens.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-zinc-500">No tokens</td></tr>
|
||||
)}
|
||||
{tokens.map(t => (
|
||||
<tr key={t.tokenMasked + t.label} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3 font-mono text-xs text-zinc-400">{t.tokenMasked}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{t.label}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(t.expiresAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-400 text-xs">
|
||||
{t.usedCount}{t.maxUses ? ` / ${t.maxUses}` : ''}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.revokedAt
|
||||
? <StatusBadge variant="red">Revoked</StatusBadge>
|
||||
: t.active
|
||||
? <StatusBadge variant="green">Active</StatusBadge>
|
||||
: <StatusBadge variant="zinc">Exhausted</StatusBadge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!t.revokedAt && (
|
||||
<button
|
||||
onClick={() => { if (confirm('Revoke this token?')) revokeMutation.mutate(t.id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passkeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PasskeysSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [regForm, setRegForm] = useState({ username: '', displayName: '' });
|
||||
const [showRegForm, setShowRegForm] = useState(false);
|
||||
|
||||
const { data: passkeys = [] } = useQuery({ queryKey: ['passkeys'], queryFn: authApi.passkeys });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: authApi.deletePasskey,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passkeys'] }),
|
||||
});
|
||||
|
||||
async function addPasskey(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await authApi.registerBegin(regForm);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await authApi.registerFinish(credential);
|
||||
setShowRegForm(false);
|
||||
setRegForm({ username: '', displayName: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Registration failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Passkeys</h2>
|
||||
<button
|
||||
onClick={() => setShowRegForm(s => !s)}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
{showRegForm ? 'Cancel' : '+ Add passkey'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-400 text-sm px-3 py-2 rounded-md">{error}</div>
|
||||
)}
|
||||
|
||||
{showRegForm && (
|
||||
<form onSubmit={addPasskey} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-3">
|
||||
<p className="text-sm font-medium text-zinc-300">Register new passkey</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Username</label>
|
||||
<input required value={regForm.username} onChange={e => setRegForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Display name</label>
|
||||
<input required value={regForm.displayName} onChange={e => setRegForm(f => ({ ...f, displayName: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={busy}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Waiting for passkey…' : 'Register passkey'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">ID</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Registered</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Last used</th>
|
||||
<th className="text-right px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{passkeys.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">No passkeys</td></tr>
|
||||
)}
|
||||
{passkeys.map(p => (
|
||||
<tr key={p.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3 font-mono text-xs text-zinc-400">{p.idMasked}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge variant={p.backedUp ? 'indigo' : 'zinc'}>
|
||||
{p.deviceType ?? 'unknown'}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(p.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(p.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{passkeys.length > 1 && (
|
||||
<button
|
||||
onClick={() => { if (confirm('Remove this passkey?')) deleteMutation.mutate(p.id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Admin() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Admin</h1>
|
||||
<TokensSection />
|
||||
<PasskeysSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
PatchProbe.Dashboard/src/pages/Dashboard.jsx
Normal file
84
PatchProbe.Dashboard/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { devicesApi, scansApi } from '../lib/api.js';
|
||||
|
||||
function StatCard({ label, value, sub, to }) {
|
||||
const inner = (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className="text-3xl font-semibold text-zinc-100">{value ?? '—'}</p>
|
||||
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
return to ? <Link to={to} className="hover:border-indigo-700 transition-colors rounded-xl block">{inner}</Link> : inner;
|
||||
}
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: devices } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
const { data: scans } = useQuery({ queryKey: ['scans'], queryFn: scansApi.list });
|
||||
|
||||
const activeDevices = devices?.filter(d => !d.revoked) ?? [];
|
||||
const revokedDevices = devices?.filter(d => d.revoked) ?? [];
|
||||
const pendingReboots = scans?.filter(s => s.pendingReboot) ?? [];
|
||||
const recent = scans?.slice(0, 10) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Dashboard</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Active Devices" value={activeDevices.length} sub={`${revokedDevices.length} revoked`} to="/devices" />
|
||||
<StatCard label="Total Scans" value={scans?.length} sub="all time" to="/scans" />
|
||||
<StatCard label="Pending Reboots" value={pendingReboots.length} sub="across all scans" />
|
||||
<StatCard label="Updates Detected" value={scans?.reduce((n, s) => n + (s.applicableUpdateCount ?? 0), 0)} sub="total across all scans" />
|
||||
</div>
|
||||
|
||||
{/* Recent scans */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Recent Scans</h2>
|
||||
<Link to="/scans" className="text-xs text-indigo-400 hover:text-indigo-300">View all →</Link>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Machine</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Collected</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updates</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Reboot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{recent.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-zinc-500">No scans yet</td></tr>
|
||||
)}
|
||||
{recent.map(s => (
|
||||
<tr key={s.id} className="hover:bg-zinc-800/50">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/scans/${s.id}`} className="text-zinc-100 hover:text-indigo-400">
|
||||
{s.machineName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{fmt(s.collectedAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{s.pendingReboot
|
||||
? <span className="text-amber-400">Yes</span>
|
||||
: <span className="text-zinc-500">No</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
PatchProbe.Dashboard/src/pages/DeviceDetail.jsx
Normal file
110
PatchProbe.Dashboard/src/pages/DeviceDetail.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams();
|
||||
const { data: devices = [] } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
const { data: scans = [], isLoading } = useQuery({
|
||||
queryKey: ['device-scans', id],
|
||||
queryFn: () => devicesApi.scans(id),
|
||||
});
|
||||
|
||||
const device = devices.find(d => d.id === id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Link to="/devices" className="hover:text-zinc-300">Devices</Link>
|
||||
<span>/</span>
|
||||
<span className="text-zinc-300">{device?.machineName ?? id}</span>
|
||||
</div>
|
||||
|
||||
{/* Device info */}
|
||||
{device && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Machine name</p>
|
||||
<p className="text-sm text-zinc-100 font-medium">{device.machineName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Status</p>
|
||||
{device.revoked
|
||||
? <StatusBadge variant="red">Revoked</StatusBadge>
|
||||
: <StatusBadge variant="green">Active</StatusBadge>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Enrolled</p>
|
||||
<p className="text-xs text-zinc-300">{fmt(device.enrolledAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Last seen</p>
|
||||
<p className="text-xs text-zinc-300">{fmt(device.lastSeenAt)}</p>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Device ID</p>
|
||||
<p className="text-xs text-zinc-500 font-mono">{device.id}</p>
|
||||
</div>
|
||||
{device.deviceFingerprint && (
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Fingerprint</p>
|
||||
<p className="text-xs text-zinc-500 font-mono">{device.deviceFingerprint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scans */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider mb-3">Scan History</h2>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Collected</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updates</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Reboot</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Admin</th>
|
||||
<th className="text-right px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{isLoading && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && scans.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">No scans yet</td></tr>
|
||||
)}
|
||||
{scans.map(s => (
|
||||
<tr key={s.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3 text-zinc-300">{fmt(s.collectedAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.pendingReboot
|
||||
? <StatusBadge variant="amber">Yes</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.ranAsAdministrator
|
||||
? <StatusBadge variant="indigo">Admin</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link to={`/scans/${s.id}`} className="text-xs text-indigo-400 hover:text-indigo-300">View →</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
PatchProbe.Dashboard/src/pages/Devices.jsx
Normal file
95
PatchProbe.Dashboard/src/pages/Devices.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState('active'); // 'active' | 'all'
|
||||
const { data: devices = [], isLoading } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: devicesApi.revoke,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }),
|
||||
});
|
||||
|
||||
const visible = filter === 'active' ? devices.filter(d => !d.revoked) : devices;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Devices</h1>
|
||||
<div className="flex gap-1 bg-zinc-800 rounded-lg p-0.5">
|
||||
{['active', 'all'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md capitalize transition-colors ${
|
||||
filter === f ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Machine</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Enrolled</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Last Seen</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{isLoading && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-500">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && visible.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-500">No devices found</td></tr>
|
||||
)}
|
||||
{visible.map(d => (
|
||||
<tr key={d.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/devices/${d.id}`} className="text-zinc-100 hover:text-indigo-400 font-medium">
|
||||
{d.machineName}
|
||||
</Link>
|
||||
<p className="text-xs text-zinc-600 mt-0.5 font-mono">{d.id.slice(0, 8)}…</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(d.enrolledAt)}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(d.lastSeenAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{d.revoked
|
||||
? <StatusBadge variant="red">Revoked</StatusBadge>
|
||||
: <StatusBadge variant="green">Active</StatusBadge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!d.revoked && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Revoke ${d.machineName}?`)) revokeMutation.mutate(d.id);
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
PatchProbe.Dashboard/src/pages/Login.jsx
Normal file
132
PatchProbe.Dashboard/src/pages/Login.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { authApi } from '../lib/api.js';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState(null); // { hasUsers }
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [regForm, setRegForm] = useState({ username: '', displayName: '' });
|
||||
|
||||
useEffect(() => {
|
||||
authApi.status().then(setStatus).catch(() => setStatus({ hasUsers: true }));
|
||||
}, []);
|
||||
|
||||
async function onSignIn() {
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await authApi.loginBegin();
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
await authApi.loginFinish(credential);
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Sign-in failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegister(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await authApi.registerBegin(regForm);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await authApi.registerFinish(credential);
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Registration failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">PatchProbe</h1>
|
||||
<p className="text-sm text-zinc-500 mt-1">Patch management dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-400 text-sm px-3 py-2 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First-time setup */}
|
||||
{status && !status.hasUsers && (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400 mb-4">
|
||||
No admin account exists yet. Register your first passkey to get started.
|
||||
</p>
|
||||
<form onSubmit={onRegister} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={regForm.username}
|
||||
onChange={e => setRegForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Display name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={regForm.displayName}
|
||||
onChange={e => setRegForm(f => ({ ...f, displayName: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Admin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Registering…' : 'Register passkey'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign in */}
|
||||
{status && status.hasUsers && (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400 mb-4">
|
||||
Sign in using a passkey registered on this device.
|
||||
</p>
|
||||
<button
|
||||
onClick={onSignIn}
|
||||
disabled={busy}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Waiting for passkey…' : 'Sign in with passkey'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && (
|
||||
<p className="text-sm text-zinc-500 text-center">Connecting…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
PatchProbe.Dashboard/src/pages/ScanDetail.jsx
Normal file
110
PatchProbe.Dashboard/src/pages/ScanDetail.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { scansApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
import JsonViewer from '../components/JsonViewer.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
function Section({ title, data, defaultOpen = false }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isEmpty = data == null || (Array.isArray(data) && data.length === 0) || (typeof data === 'object' && Object.keys(data).length === 0);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-zinc-200">{title}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isEmpty && <span className="text-xs text-zinc-600">empty</span>}
|
||||
<span className="text-zinc-500">{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 py-4 border-t border-zinc-800 overflow-x-auto">
|
||||
{isEmpty
|
||||
? <p className="text-zinc-500 text-sm">No data</p>
|
||||
: <JsonViewer data={data} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanDetail() {
|
||||
const { id } = useParams();
|
||||
const { data: scan, isLoading, error } = useQuery({
|
||||
queryKey: ['scan', id],
|
||||
queryFn: () => scansApi.get(id),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-zinc-500">Loading…</div>;
|
||||
if (error) return <div className="text-red-400">Failed to load scan: {error.message}</div>;
|
||||
|
||||
const c = scan?.collector;
|
||||
const wu = scan?.windowsUpdate;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Link to="/scans" className="hover:text-zinc-300">Scans</Link>
|
||||
<span>/</span>
|
||||
<span className="text-zinc-300">{c?.machineName}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Machine</p>
|
||||
<p className="text-sm font-medium text-zinc-100">{c?.machineName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Collected</p>
|
||||
<p className="text-xs text-zinc-300">{fmt(c?.collectedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Applicable Updates</p>
|
||||
<p className="text-2xl font-semibold text-zinc-100">{wu?.applicableUpdates?.length ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Pending Reboot</p>
|
||||
{scan?.pendingReboot?.anyPending
|
||||
? <StatusBadge variant="amber">Yes</StatusBadge>
|
||||
: <StatusBadge variant="green">No</StatusBadge>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Ran as Admin</p>
|
||||
{c?.ranAsAdministrator
|
||||
? <StatusBadge variant="indigo">Yes</StatusBadge>
|
||||
: <StatusBadge variant="red">No</StatusBadge>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Schema</p>
|
||||
<p className="text-xs text-zinc-500">{scan?.schemaVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-3">
|
||||
<Section title="OS" data={scan?.os} defaultOpen />
|
||||
<Section title="Device" data={scan?.device} />
|
||||
<Section title="Applicable Updates" data={wu?.applicableUpdates} />
|
||||
<Section title="Update History" data={wu?.history} />
|
||||
<Section title="Windows Update Policy" data={wu?.policy} />
|
||||
<Section title="Pending Reboot" data={scan?.pendingReboot} />
|
||||
<Section title="Installed Hotfixes" data={scan?.installedHotfixes} />
|
||||
<Section title="CBS / DISM Packages" data={scan?.cbsPackages} />
|
||||
<Section title="Drivers" data={scan?.drivers} />
|
||||
<Section title="Recent Update Events" data={scan?.recentUpdateEvents} />
|
||||
<Section title="Collector Metadata" data={scan?.collector} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
PatchProbe.Dashboard/src/pages/Scans.jsx
Normal file
93
PatchProbe.Dashboard/src/pages/Scans.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { scansApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Scans() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: scans = [], isLoading } = useQuery({ queryKey: ['scans'], queryFn: scansApi.list });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scansApi.remove,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scans'] }),
|
||||
});
|
||||
|
||||
const visible = search
|
||||
? scans.filter(s => s.machineName?.toLowerCase().includes(search.toLowerCase()))
|
||||
: scans;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Scans</h1>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter by machine…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Machine</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Collected</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updates</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Reboot</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Admin</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{isLoading && (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-zinc-500">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && visible.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-zinc-500">No scans found</td></tr>
|
||||
)}
|
||||
{visible.map(s => (
|
||||
<tr key={s.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/scans/${s.id}`} className="text-zinc-100 hover:text-indigo-400 font-medium">
|
||||
{s.machineName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(s.collectedAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.pendingReboot
|
||||
? <StatusBadge variant="amber">Yes</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.ranAsAdministrator
|
||||
? <StatusBadge variant="indigo">Admin</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-3">
|
||||
<Link to={`/scans/${s.id}`} className="text-xs text-indigo-400 hover:text-indigo-300">View</Link>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this scan?')) deleteMutation.mutate(s.id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
PatchProbe.Dashboard/vite.config.js
Normal file
16
PatchProbe.Dashboard/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: '../PatchProbe.Server/public',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PatchProbe.Engine.Contracts.ApiModels;
|
||||
|
||||
public sealed record EnrollmentRequest(
|
||||
string EnrollmentKey,
|
||||
string MachineName,
|
||||
string DeviceFingerprint,
|
||||
string PublicKeySpki);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace PatchProbe.Engine.Contracts.ApiModels;
|
||||
|
||||
public sealed record EnrollmentResponse(
|
||||
string DeviceId,
|
||||
string Message);
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
6
PatchProbe.Shared/Contracts/ICollector.cs
Normal file
6
PatchProbe.Shared/Contracts/ICollector.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace PatchProbe.Shared.Contracts;
|
||||
|
||||
public interface ICollector<T>
|
||||
{
|
||||
Task<T> CollectAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
8
PatchProbe.Shared/Contracts/IPayloadUploader.cs
Normal file
8
PatchProbe.Shared/Contracts/IPayloadUploader.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Shared.Contracts;
|
||||
|
||||
public interface IPayloadUploader
|
||||
{
|
||||
Task UploadAsync(PatchProbePayload payload, CancellationToken cancellationToken = default);
|
||||
}
|
||||
14
PatchProbe.Shared/Models/ApplicableUpdate.cs
Normal file
14
PatchProbe.Shared/Models/ApplicableUpdate.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class ApplicableUpdate
|
||||
{
|
||||
public string? UpdateId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? KbArticleId { get; init; }
|
||||
public string? Category { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public bool RebootRequired { get; init; }
|
||||
public bool IsDownloaded { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? SupportUrl { get; init; }
|
||||
}
|
||||
9
PatchProbe.Shared/Models/CbsPackage.cs
Normal file
9
PatchProbe.Shared/Models/CbsPackage.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class CbsPackage
|
||||
{
|
||||
public string? PackageIdentity { get; init; }
|
||||
public string? State { get; init; }
|
||||
public string? ReleaseType { get; init; }
|
||||
public string? InstallTime { get; init; }
|
||||
}
|
||||
10
PatchProbe.Shared/Models/CollectorMeta.cs
Normal file
10
PatchProbe.Shared/Models/CollectorMeta.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class CollectorMeta
|
||||
{
|
||||
public string SchemaVersion { get; init; } = "0.1";
|
||||
public string CollectorVersion { get; init; } = "1.0.0";
|
||||
public DateTimeOffset CollectedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public string? MachineName { get; init; }
|
||||
public bool RanAsAdministrator { get; init; }
|
||||
}
|
||||
17
PatchProbe.Shared/Models/DeviceInfo.cs
Normal file
17
PatchProbe.Shared/Models/DeviceInfo.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class DeviceInfo
|
||||
{
|
||||
public string? Hostname { get; init; }
|
||||
public string? Manufacturer { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public string? SerialNumber { get; init; }
|
||||
public string? BiosVersion { get; init; }
|
||||
public string? BiosDate { get; init; }
|
||||
public bool TpmPresent { get; init; }
|
||||
public string? TpmVersion { get; init; }
|
||||
public ulong RamBytes { get; init; }
|
||||
public string? Domain { get; init; }
|
||||
public string? Workgroup { get; init; }
|
||||
public string? SystemType { get; init; }
|
||||
}
|
||||
13
PatchProbe.Shared/Models/DriverInfo.cs
Normal file
13
PatchProbe.Shared/Models/DriverInfo.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class DriverInfo
|
||||
{
|
||||
public string? DeviceName { get; init; }
|
||||
public string? DriverVersion { get; init; }
|
||||
public string? Manufacturer { get; init; }
|
||||
public string? InfName { get; init; }
|
||||
public string? HardwareId { get; init; }
|
||||
public string? DeviceClass { get; init; }
|
||||
public DateTimeOffset? DriverDate { get; init; }
|
||||
public bool IsSigned { get; init; }
|
||||
}
|
||||
9
PatchProbe.Shared/Models/InstalledHotfix.cs
Normal file
9
PatchProbe.Shared/Models/InstalledHotfix.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class InstalledHotfix
|
||||
{
|
||||
public string? HotFixId { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? InstalledBy { get; init; }
|
||||
public DateTimeOffset? InstalledOn { get; init; }
|
||||
}
|
||||
14
PatchProbe.Shared/Models/OsInfo.cs
Normal file
14
PatchProbe.Shared/Models/OsInfo.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class OsInfo
|
||||
{
|
||||
public string? ProductName { get; init; }
|
||||
public string? EditionId { get; init; }
|
||||
public string? DisplayVersion { get; init; }
|
||||
public string? ReleaseId { get; init; }
|
||||
public string? BuildNumber { get; init; }
|
||||
public int Ubr { get; init; }
|
||||
public string? Architecture { get; init; }
|
||||
public DateTimeOffset? InstallDate { get; init; }
|
||||
public DateTimeOffset? LastBoot { get; init; }
|
||||
}
|
||||
14
PatchProbe.Shared/Models/PatchProbePayload.cs
Normal file
14
PatchProbe.Shared/Models/PatchProbePayload.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class PatchProbePayload
|
||||
{
|
||||
public CollectorMeta Collector { get; init; } = new();
|
||||
public DeviceInfo? Device { get; init; }
|
||||
public OsInfo? Os { get; init; }
|
||||
public PendingRebootInfo? PendingReboot { get; init; }
|
||||
public WindowsUpdateInfo? WindowsUpdate { get; init; }
|
||||
public List<InstalledHotfix> InstalledHotfixes { get; init; } = [];
|
||||
public List<CbsPackage> CbsPackages { get; init; } = [];
|
||||
public List<DriverInfo> Drivers { get; init; } = [];
|
||||
public List<UpdateEvent> RecentUpdateEvents { get; init; } = [];
|
||||
}
|
||||
10
PatchProbe.Shared/Models/PendingRebootInfo.cs
Normal file
10
PatchProbe.Shared/Models/PendingRebootInfo.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class PendingRebootInfo
|
||||
{
|
||||
public bool CbsRebootPending { get; init; }
|
||||
public bool WindowsUpdateRebootRequired { get; init; }
|
||||
public bool SessionManagerRebootRequired { get; init; }
|
||||
public bool ComputerRenameRequired { get; init; }
|
||||
public bool AnyPending => CbsRebootPending || WindowsUpdateRebootRequired || SessionManagerRebootRequired || ComputerRenameRequired;
|
||||
}
|
||||
11
PatchProbe.Shared/Models/UpdateEvent.cs
Normal file
11
PatchProbe.Shared/Models/UpdateEvent.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class UpdateEvent
|
||||
{
|
||||
public long EventId { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? LogName { get; init; }
|
||||
public string? Level { get; init; }
|
||||
public DateTimeOffset? TimeCreated { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
13
PatchProbe.Shared/Models/UpdateHistoryEntry.cs
Normal file
13
PatchProbe.Shared/Models/UpdateHistoryEntry.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class UpdateHistoryEntry
|
||||
{
|
||||
public string? UpdateId { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? KbArticleId { get; init; }
|
||||
public int ResultCode { get; init; }
|
||||
public int HResult { get; init; }
|
||||
public DateTimeOffset? Date { get; init; }
|
||||
public string? Operation { get; init; }
|
||||
public string? ServerSelection { get; init; }
|
||||
}
|
||||
9
PatchProbe.Shared/Models/WindowsUpdateInfo.cs
Normal file
9
PatchProbe.Shared/Models/WindowsUpdateInfo.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class WindowsUpdateInfo
|
||||
{
|
||||
public List<ApplicableUpdate> ApplicableUpdates { get; init; } = [];
|
||||
public List<UpdateHistoryEntry> History { get; init; } = [];
|
||||
public WindowsUpdatePolicy? Policy { get; init; }
|
||||
public string? SearchError { get; init; }
|
||||
}
|
||||
16
PatchProbe.Shared/Models/WindowsUpdatePolicy.cs
Normal file
16
PatchProbe.Shared/Models/WindowsUpdatePolicy.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace PatchProbe.Shared.Models;
|
||||
|
||||
public sealed class WindowsUpdatePolicy
|
||||
{
|
||||
public bool WsusConfigured { get; init; }
|
||||
public string? WsusServer { get; init; }
|
||||
public string? WsusStatusServer { get; init; }
|
||||
public bool WufbConfigured { get; init; }
|
||||
public int? DeferFeatureUpdatesDays { get; init; }
|
||||
public int? DeferQualityUpdatesDays { get; init; }
|
||||
public bool AutoUpdateEnabled { get; init; }
|
||||
public int? AuOptions { get; init; }
|
||||
public bool TargetGroupEnabled { get; init; }
|
||||
public string? TargetGroup { get; init; }
|
||||
public string? BranchReadinessLevel { get; init; }
|
||||
}
|
||||
9
PatchProbe.Shared/PatchProbe.Shared.csproj
Normal file
9
PatchProbe.Shared/PatchProbe.Shared.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
25
PatchProbe.Shared/Serialization/PayloadSerializer.cs
Normal file
25
PatchProbe.Shared/Serialization/PayloadSerializer.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using PatchProbe.Shared.Models;
|
||||
|
||||
namespace PatchProbe.Shared.Serialization;
|
||||
|
||||
public static class PayloadSerializer
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public static string Serialize(PatchProbePayload payload) =>
|
||||
JsonSerializer.Serialize(payload, Options);
|
||||
|
||||
public static async Task SerializeToFileAsync(PatchProbePayload payload, string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var stream = File.Create(path);
|
||||
await JsonSerializer.SerializeAsync(stream, payload, Options, cancellationToken);
|
||||
}
|
||||
}
|
||||
5
PatchProbe.slnx
Normal file
5
PatchProbe.slnx
Normal file
@@ -0,0 +1,5 @@
|
||||
<Solution>
|
||||
<Project Path="PatchProbe.Cli/PatchProbe.Cli.csproj" />
|
||||
<Project Path="PatchProbe.Engine.Contracts/PatchProbe.Engine.Contracts.csproj" />
|
||||
<Project Path="PatchProbe.Shared/PatchProbe.Shared.csproj" />
|
||||
</Solution>
|
||||
6
PatchProbe/App.config
Normal file
6
PatchProbe/App.config
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
53
PatchProbe/PatchProbe.csproj
Normal file
53
PatchProbe/PatchProbe.csproj
Normal file
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{FC689B9E-18B0-4DD8-964E-3A4D9D093BFD}</ProjectGuid>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>PatchProbe</RootNamespace>
|
||||
<AssemblyName>PatchProbe</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<Deterministic>true</Deterministic>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Data.DataSetExtensions" />
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Program.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="App.config" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
15
PatchProbe/Program.cs
Normal file
15
PatchProbe/Program.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PatchProbe
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
33
PatchProbe/Properties/AssemblyInfo.cs
Normal file
33
PatchProbe/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyTitle("PatchProbe")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("PatchProbe")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2026")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// Setting ComVisible to false makes the types in this assembly not visible
|
||||
// to COM components. If you need to access a type in this assembly from
|
||||
// COM, set the ComVisible attribute to true on that type.
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
// The following GUID is for the ID of the typelib if this project is exposed to COM
|
||||
[assembly: Guid("fc689b9e-18b0-4dd8-964e-3a4d9d093bfd")]
|
||||
|
||||
// Version information for an assembly consists of the following four values:
|
||||
//
|
||||
// Major Version
|
||||
// Minor Version
|
||||
// Build Number
|
||||
// Revision
|
||||
//
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
Reference in New Issue
Block a user