diff --git a/Program.cs b/Program.cs index dfc2d74..73e7a41 100644 --- a/Program.cs +++ b/Program.cs @@ -620,6 +620,9 @@ private static int SaveSettingsFromArgs(string[] args) static async Task ProcessManifest(string manifestUrl, bool forceDownload = false, bool noDialog = false, bool blurScreen = false, string dialogTitle = "Setting Up Your Device", string dialogMessage = "Please wait while we install required software...") { + // Captured before the try so both the success and failure paths can + // report an accurate duration. + DateTime runStartUtc = DateTime.UtcNow; try { // Suppress system sounds for silent installation experience @@ -765,7 +768,10 @@ private static int SaveSettingsFromArgs(string[] args) // Restore system sounds before completion RestoreSystemSounds(); - + + // Post a vendor-neutral run summary to the optional reporting endpoint. + await ReportManager.SendRunSummaryAsync(true, runStartUtc, Version, manifestUrl); + return 0; } catch (Exception ex) @@ -805,7 +811,10 @@ private static int SaveSettingsFromArgs(string[] args) { // Don't let status update failures mask the original error } - + + // Report the failed run too, so the fleet view reflects failures. + await ReportManager.SendRunSummaryAsync(false, runStartUtc, Version, manifestUrl); + return 1; } } diff --git a/README.md b/README.md index 650050a..4ea7259 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ Before building, set up your environment variables: 3. **Install your code signing certificate** in the Current User certificate store +## Reporting + +When a run completes, BootstrapMate can POST a vendor-neutral JSON run summary to an optional endpoint, turning "did this PC provision cleanly?" into a fleet-dashboard query. The payload is plain JSON and not tied to any specific backend — any service accepting a JSON POST (a custom collector, ReportMate, MunkiReport, etc.) can consume it. Both the Windows and macOS clients emit the same schema. + +Configure via Intune CSP / Group Policy (the bundled ADMX), the machine/user registry, or both keys below: + +| Key | Type | Effect | +|---|---|---| +| `ReportingUrl` | string | Endpoint to POST the run summary to. When unset, no report is sent. | +| `ReportingHeader` | string | Optional `Authorization` header value sent with the POST. | + +The POST is best-effort: it is bounded by a short timeout and never fails the run (a slow or unreachable endpoint can delay completion by up to that timeout). Payload fields include `tool`, `platform`, `version`, `runId`, `success`, `startTime`/`endTime`, `durationSeconds`, `architecture`, `hostname`, `serialNumber`, `manifestUrl`, and per-phase outcomes. ## Security: package signature verification Before any MSI or EXE installer is executed elevated, BootstrapMate verifies its Authenticode signature with `WinVerifyTrust`. A successful download only proves where the bytes came from — not who produced them. The signature gate ensures an installer carries a signature that chains to a trusted root (and, when configured, matches an expected publisher) before it runs as an elevated process. diff --git a/ReportManager.cs b/ReportManager.cs new file mode 100644 index 0000000..29d6f68 --- /dev/null +++ b/ReportManager.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Win32; +using BootstrapMate.Core; + +namespace BootstrapMate +{ + /// + /// Posts a vendor-neutral run summary to an optional reporting endpoint when a + /// bootstrap run completes, turning "did this PC provision cleanly?" into a + /// fleet-dashboard query instead of an RDP/registry expedition. + /// + /// The payload is plain JSON and intentionally NOT specific to any one backend + /// (ReportMate, MunkiReport, a custom collector, …). Windows counterpart of the + /// macOS ReportManager; emits the same schema. + /// + public static class ReportManager + { + /// + /// Build and POST the run summary to the configured reporting endpoint, if any. + /// Best-effort: failures are logged and never abort the run. + /// + public static async Task SendRunSummaryAsync(bool success, DateTime startTimeUtc, string version, string manifestUrl) + { + var config = ConfigManager.Instance.Config; + var url = config.ReportingUrl; + if (string.IsNullOrWhiteSpace(url)) + return; + + try + { + // Use the manifest URL actually used for this run (which may come + // from --url), not whatever happens to be in config. + var payload = BuildPayload(success, startTimeUtc, DateTime.UtcNow, version, manifestUrl); + var json = JsonSerializer.Serialize(payload); + + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(15) }; + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", $"BootstrapMate/{version}"); + if (!string.IsNullOrWhiteSpace(config.ReportingHeader)) + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", config.ReportingHeader); + + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + Logger.Info($"Posting run summary to reporting endpoint: {url}"); + using var response = await httpClient.PostAsync(url, content); + if (response.IsSuccessStatusCode) + Logger.Info($"Run summary reported (HTTP {(int)response.StatusCode})"); + else + Logger.Warning($"Reporting endpoint returned HTTP {(int)response.StatusCode}"); + } + catch (Exception ex) + { + Logger.Warning($"Reporting POST failed: {ex.Message}"); + } + } + + /// Assemble the vendor-neutral summary. Mirrors the macOS schema. + public static Dictionary BuildPayload( + bool success, DateTime startTimeUtc, DateTime endTimeUtc, string version, string manifestUrl) + { + return new Dictionary + { + ["tool"] = "BootstrapMate", + ["platform"] = "Windows", + ["schemaVersion"] = 1, + ["version"] = version, + ["runId"] = StatusManager.GetCurrentRunId(), + ["success"] = success, + ["startTime"] = startTimeUtc.ToString("o"), + ["endTime"] = endTimeUtc.ToString("o"), + ["durationSeconds"] = (int)Math.Round((endTimeUtc - startTimeUtc).TotalSeconds), + ["architecture"] = CurrentArchitecture(), + ["hostname"] = Environment.MachineName, + ["serialNumber"] = SerialNumber() ?? "", + ["manifestUrl"] = manifestUrl, + ["phases"] = CollectPhases() + }; + } + + private static Dictionary CollectPhases() + { + var phases = new Dictionary(); + foreach (var phase in new[] { InstallationPhase.SetupAssistant, InstallationPhase.Userland }) + { + var status = StatusManager.GetPhaseStatus(phase); + phases[phase.ToString()] = new Dictionary + { + ["stage"] = status.Stage.ToString(), + ["exitCode"] = status.ExitCode, + ["startTime"] = status.StartTime, + ["completionTime"] = status.CompletionTime, + ["lastError"] = status.LastError + }; + } + return phases; + } + + private static string CurrentArchitecture() + => RuntimeInformation.OSArchitecture switch + { + Architecture.Arm64 => "ARM64", + Architecture.X64 => "X64", + Architecture.X86 => "X86", + var other => other.ToString().ToUpperInvariant() + }; + + /// Best-effort hardware serial number from the SMBIOS registry key. + private static string? SerialNumber() + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\BIOS"); + return key?.GetValue("SystemSerialNumber") as string; + } + catch + { + return null; + } + } + } +} diff --git a/resources/BootstrapMate.admx b/resources/BootstrapMate.admx index c48f9e5..cd92026 100644 --- a/resources/BootstrapMate.admx +++ b/resources/BootstrapMate.admx @@ -36,6 +36,7 @@ + @@ -225,6 +226,32 @@ + + + + + + + + + + + + + + + + Behavior Dialog Advanced + Reporting Security @@ -101,6 +102,20 @@ Default: C:\Program Files\BootstrapMate Range: 10–600 seconds Default: 120 seconds + + Reporting URL + URL to POST a vendor-neutral JSON run summary to when a bootstrap run completes. + +The payload includes the run ID, version, success, duration, architecture, hostname, serial number, manifest URL, and per-phase outcomes. Any endpoint that accepts a JSON POST can consume it. + +Leave empty to disable reporting. The POST is best-effort: it is bounded by a short timeout and never fails the run (a slow or unreachable endpoint can delay completion by up to that timeout). + + Reporting Authorization Header + Optional Authorization header value sent with the reporting POST request. + +Example: Bearer eyJhbGciOiJIUzI1NiIs... + +Leave empty if your reporting endpoint does not require authentication. Verify package signatures Verify the Authenticode signature of MSI/EXE installers before running them elevated. The download only proves where bytes came from, not who produced them. @@ -161,6 +176,15 @@ Default: Disabled Timeout (seconds): + + + + + + + + + diff --git a/src/BootstrapMate.Core/BootstrapMateConfig.cs b/src/BootstrapMate.Core/BootstrapMateConfig.cs index bbb50d4..118b842 100644 --- a/src/BootstrapMate.Core/BootstrapMateConfig.cs +++ b/src/BootstrapMate.Core/BootstrapMateConfig.cs @@ -25,6 +25,9 @@ public sealed class BootstrapMateConfig public bool BlurScreen { get; set; } public bool NoDialog { get; set; } + // Reporting: vendor-neutral run-summary POST + public string? ReportingUrl { get; set; } + public string? ReportingHeader { get; set; } // Security: installer signature verification public bool VerifyPackageSignatures { get; set; } = true; public string? ExpectedPublisher { get; set; } diff --git a/src/BootstrapMate.Core/ConfigManager.cs b/src/BootstrapMate.Core/ConfigManager.cs index c7b543c..2afaf7c 100644 --- a/src/BootstrapMate.Core/ConfigManager.cs +++ b/src/BootstrapMate.Core/ConfigManager.cs @@ -50,6 +50,8 @@ public void ApplyCliArguments( string? dialogTitle = null, string? dialogMessage = null, int? networkTimeout = null, + string? reportingUrl = null, + string? reportingHeader = null, bool? verifyPackageSignatures = null, string? expectedPublisher = null, bool? allowUnsigned = null) @@ -79,6 +81,10 @@ public void ApplyCliArguments( Config.DialogMessage = dialogMessage; if (networkTimeout.HasValue) Config.NetworkTimeout = networkTimeout.Value; + if (!string.IsNullOrWhiteSpace(reportingUrl)) + Config.ReportingUrl = reportingUrl; + if (!string.IsNullOrWhiteSpace(reportingHeader)) + Config.ReportingHeader = reportingHeader; if (verifyPackageSignatures.HasValue) Config.VerifyPackageSignatures = verifyPackageSignatures.Value; if (!string.IsNullOrWhiteSpace(expectedPublisher)) @@ -153,6 +159,8 @@ void WriteInt(string key, int value) WriteBool("BlurScreen", settings.BlurScreen); WriteString("CustomInstallPath", settings.CustomInstallPath); WriteInt("NetworkTimeout", settings.NetworkTimeout); + WriteString("ReportingUrl", settings.ReportingUrl); + WriteString("ReportingHeader", settings.ReportingHeader); WriteBool("VerifyPackageSignatures", settings.VerifyPackageSignatures); WriteString("ExpectedPublisher", settings.ExpectedPublisher); WriteBool("AllowUnsigned", settings.AllowUnsigned); @@ -228,6 +236,11 @@ private void LoadFromManagement(ManagementDetector management) if (management.GetManagedInt("NetworkTimeout") is { } timeout) Config.NetworkTimeout = timeout; + if (management.GetManagedString("ReportingUrl") is { Length: > 0 } reportingUrl) + Config.ReportingUrl = reportingUrl; + + if (management.GetManagedString("ReportingHeader") is { Length: > 0 } reportingHeader) + Config.ReportingHeader = reportingHeader; if (management.GetManagedBool("VerifyPackageSignatures") is { } verifySig) Config.VerifyPackageSignatures = verifySig; @@ -281,6 +294,8 @@ private void LoadFromHive(RegistryHive hive, ConfigSource source, RegistryView v Config.BlurScreen = ReadBool(settingsKey, "BlurScreen") ?? Config.BlurScreen; Config.CustomInstallPath = ReadString(settingsKey, "CustomInstallPath") ?? Config.CustomInstallPath; Config.NetworkTimeout = ReadInt(settingsKey, "NetworkTimeout") ?? Config.NetworkTimeout; + Config.ReportingUrl = ReadString(settingsKey, "ReportingUrl") ?? Config.ReportingUrl; + Config.ReportingHeader = ReadString(settingsKey, "ReportingHeader") ?? Config.ReportingHeader; Config.VerifyPackageSignatures = ReadBool(settingsKey, "VerifyPackageSignatures") ?? Config.VerifyPackageSignatures; Config.ExpectedPublisher = ReadString(settingsKey, "ExpectedPublisher") ?? Config.ExpectedPublisher; Config.AllowUnsigned = ReadBool(settingsKey, "AllowUnsigned") ?? Config.AllowUnsigned; diff --git a/src/BootstrapMate.Core/ManagementDetector.cs b/src/BootstrapMate.Core/ManagementDetector.cs index 0089ef2..7b8b60e 100644 --- a/src/BootstrapMate.Core/ManagementDetector.cs +++ b/src/BootstrapMate.Core/ManagementDetector.cs @@ -35,6 +35,8 @@ public sealed class ManagementDetector ["BlurScreen"] = ["BlurScreen"], ["CustomInstallPath"] = ["CustomInstallPath", "InstallPath", "iapath"], ["NetworkTimeout"] = ["NetworkTimeout"], + ["ReportingUrl"] = ["ReportingUrl", "ReportURL", "reportingUrl"], + ["ReportingHeader"] = ["ReportingHeader", "ReportingAuthorizationHeader"], ["VerifyPackageSignatures"] = ["VerifyPackageSignatures", "VerifySignatures"], ["ExpectedPublisher"] = ["ExpectedPublisher", "Publisher"], ["AllowUnsigned"] = ["AllowUnsigned"],