From bd8dee98e35e04d3a1fe1f91600cc94ddf0857d6 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 14 Jun 2026 23:07:42 -0700 Subject: [PATCH 1/2] Post a vendor-neutral run summary on completion BootstrapMate status was local-only (registry + status.json + logs), so checking whether a PC provisioned cleanly meant an RDP/registry expedition. Add an optional reporting POST: when ReportingUrl is configured, a JSON run summary is sent on completion (success and failure paths). The payload is backend-agnostic and emits the SAME schema as the macOS client, so one fleet view covers both platforms. It includes runId, version, success, start/end/duration, architecture, hostname, serial number, manifest URL, and per-phase outcomes (reusing StatusManager data). The POST is best-effort: failures are logged and never block or fail the run. Configurable via Intune CSP / Group Policy (new ADMX Reporting category: ReportingUrl, ReportingHeader) and the machine/user registry. --- Program.cs | 13 +- README.md | 13 ++ ReportManager.cs | 118 ++++++++++++++++++ resources/BootstrapMate.admx | 31 +++++ resources/en-US/BootstrapMate.adml | 28 +++++ src/BootstrapMate.Core/BootstrapMateConfig.cs | 4 + src/BootstrapMate.Core/ConfigManager.cs | 18 ++- src/BootstrapMate.Core/PolicyDetector.cs | 2 + 8 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 ReportManager.cs diff --git a/Program.cs b/Program.cs index f126c4d..b85baad 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); + 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); + return 1; } } diff --git a/README.md b/README.md index cf1e2ac..11a9cfc 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,19 @@ 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: failures are logged and never block or fail the run. Payload fields include `tool`, `platform`, `version`, `runId`, `success`, `startTime`/`endTime`, `durationSeconds`, `architecture`, `hostname`, `serialNumber`, `manifestUrl`, and per-phase outcomes. + ## Quick Start ```powershell diff --git a/ReportManager.cs b/ReportManager.cs new file mode 100644 index 0000000..9d5e745 --- /dev/null +++ b/ReportManager.cs @@ -0,0 +1,118 @@ +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) + { + var config = ConfigManager.Instance.Config; + var url = config.ReportingUrl; + if (string.IsNullOrWhiteSpace(url)) + return; + + try + { + var payload = BuildPayload(success, startTimeUtc, DateTime.UtcNow, version, config.ManifestUrl ?? ""); + var json = JsonSerializer.Serialize(payload); + + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + 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 == Architecture.Arm64 ? "ARM64" : "X64"; + + /// 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 4009aaf..2b81bed 100644 --- a/resources/BootstrapMate.admx +++ b/resources/BootstrapMate.admx @@ -36,6 +36,9 @@ + + + @@ -222,5 +225,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/en-US/BootstrapMate.adml b/resources/en-US/BootstrapMate.adml index c7cfa37..40e353c 100644 --- a/resources/en-US/BootstrapMate.adml +++ b/resources/en-US/BootstrapMate.adml @@ -20,6 +20,7 @@ Behavior Dialog Advanced + Reporting Manifest URL @@ -99,6 +100,21 @@ 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 and never blocks or fails the run. + + 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. @@ -141,6 +157,18 @@ Default: 120 seconds Timeout (seconds): + + + + + + + + + + + + diff --git a/src/BootstrapMate.Core/BootstrapMateConfig.cs b/src/BootstrapMate.Core/BootstrapMateConfig.cs index 8c72ec6..2bfbcc2 100644 --- a/src/BootstrapMate.Core/BootstrapMateConfig.cs +++ b/src/BootstrapMate.Core/BootstrapMateConfig.cs @@ -25,6 +25,10 @@ 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; } + // Advanced public string? CustomInstallPath { get; set; } public int NetworkTimeout { get; set; } = BootstrapMateConstants.DefaultNetworkTimeout; diff --git a/src/BootstrapMate.Core/ConfigManager.cs b/src/BootstrapMate.Core/ConfigManager.cs index c4c4f41..ddc3029 100644 --- a/src/BootstrapMate.Core/ConfigManager.cs +++ b/src/BootstrapMate.Core/ConfigManager.cs @@ -49,7 +49,9 @@ public void ApplyCliArguments( bool? noDialog = null, string? dialogTitle = null, string? dialogMessage = null, - int? networkTimeout = null) + int? networkTimeout = null, + string? reportingUrl = null, + string? reportingHeader = null) { if (!string.IsNullOrWhiteSpace(manifestUrl)) { @@ -76,6 +78,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; } /// Get the effective manifest URL from whatever source is active. @@ -144,6 +150,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); } // ── Private ────────────────────────────────────────────────────── @@ -215,6 +223,12 @@ private void LoadFromPolicy(PolicyDetector policy) if (policy.GetManagedInt("NetworkTimeout") is { } timeout) Config.NetworkTimeout = timeout; + + if (policy.GetManagedString("ReportingUrl") is { Length: > 0 } reportingUrl) + Config.ReportingUrl = reportingUrl; + + if (policy.GetManagedString("ReportingHeader") is { Length: > 0 } reportingHeader) + Config.ReportingHeader = reportingHeader; } private void LoadFromUserRegistry() @@ -260,6 +274,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; } catch { diff --git a/src/BootstrapMate.Core/PolicyDetector.cs b/src/BootstrapMate.Core/PolicyDetector.cs index f2de0af..d06f104 100644 --- a/src/BootstrapMate.Core/PolicyDetector.cs +++ b/src/BootstrapMate.Core/PolicyDetector.cs @@ -35,6 +35,8 @@ public sealed class PolicyDetector ["BlurScreen"] = ["BlurScreen"], ["CustomInstallPath"] = ["CustomInstallPath", "InstallPath", "iapath"], ["NetworkTimeout"] = ["NetworkTimeout"], + ["ReportingUrl"] = ["ReportingUrl", "ReportURL", "reportingUrl"], + ["ReportingHeader"] = ["ReportingHeader", "ReportingAuthorizationHeader"], }; private PolicyDetector() { } From 420d4e96790b3ff173510deae38c2b188d086271 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 15 Jun 2026 16:56:09 -0700 Subject: [PATCH 2/2] Address review: report run's actual manifest URL; fix arch + docs - Report the manifest URL actually used for the run (passed from ProcessManifest, which honors --url) rather than ConfigManager's value, which can differ. - Map architecture explicitly (ARM64/X64/X86, else uppercased name) so x86 is no longer mislabeled as X64. - Lower the POST timeout to 15s and correct README/ADML wording: the report is bounded by a short timeout (it does not 'never block'), but never fails the run. --- Program.cs | 4 ++-- README.md | 2 +- ReportManager.cs | 16 ++++++++++++---- resources/en-US/BootstrapMate.adml | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Program.cs b/Program.cs index b85baad..2ea56c8 100644 --- a/Program.cs +++ b/Program.cs @@ -770,7 +770,7 @@ private static int SaveSettingsFromArgs(string[] args) RestoreSystemSounds(); // Post a vendor-neutral run summary to the optional reporting endpoint. - await ReportManager.SendRunSummaryAsync(true, runStartUtc, Version); + await ReportManager.SendRunSummaryAsync(true, runStartUtc, Version, manifestUrl); return 0; } @@ -813,7 +813,7 @@ private static int SaveSettingsFromArgs(string[] args) } // Report the failed run too, so the fleet view reflects failures. - await ReportManager.SendRunSummaryAsync(false, runStartUtc, Version); + await ReportManager.SendRunSummaryAsync(false, runStartUtc, Version, manifestUrl); return 1; } diff --git a/README.md b/README.md index 11a9cfc..7f92f85 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Configure via Intune CSP / Group Policy (the bundled ADMX), the machine/user reg | `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: failures are logged and never block or fail the run. Payload fields include `tool`, `platform`, `version`, `runId`, `success`, `startTime`/`endTime`, `durationSeconds`, `architecture`, `hostname`, `serialNumber`, `manifestUrl`, and per-phase outcomes. +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. ## Quick Start diff --git a/ReportManager.cs b/ReportManager.cs index 9d5e745..29d6f68 100644 --- a/ReportManager.cs +++ b/ReportManager.cs @@ -25,7 +25,7 @@ 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) + public static async Task SendRunSummaryAsync(bool success, DateTime startTimeUtc, string version, string manifestUrl) { var config = ConfigManager.Instance.Config; var url = config.ReportingUrl; @@ -34,10 +34,12 @@ public static async Task SendRunSummaryAsync(bool success, DateTime startTimeUtc try { - var payload = BuildPayload(success, startTimeUtc, DateTime.UtcNow, version, config.ManifestUrl ?? ""); + // 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(30) }; + 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); @@ -99,7 +101,13 @@ public static async Task SendRunSummaryAsync(bool success, DateTime startTimeUtc } private static string CurrentArchitecture() - => RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "ARM64" : "X64"; + => 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() diff --git a/resources/en-US/BootstrapMate.adml b/resources/en-US/BootstrapMate.adml index 40e353c..2a53056 100644 --- a/resources/en-US/BootstrapMate.adml +++ b/resources/en-US/BootstrapMate.adml @@ -107,7 +107,7 @@ Default: 120 seconds 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 and never blocks or fails the run. +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.