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