Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,9 @@ private static int SaveSettingsFromArgs(string[] args)

static async Task<int> 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
126 changes: 126 additions & 0 deletions ReportManager.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class ReportManager
{
/// <summary>
/// Build and POST the run summary to the configured reporting endpoint, if any.
/// Best-effort: failures are logged and never abort the run.
/// </summary>
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}");
}
}

/// <summary>Assemble the vendor-neutral summary. Mirrors the macOS schema.</summary>
public static Dictionary<string, object?> BuildPayload(
bool success, DateTime startTimeUtc, DateTime endTimeUtc, string version, string manifestUrl)
{
return new Dictionary<string, object?>
{
["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<string, object?> CollectPhases()
{
var phases = new Dictionary<string, object?>();
foreach (var phase in new[] { InstallationPhase.SetupAssistant, InstallationPhase.Userland })
{
var status = StatusManager.GetPhaseStatus(phase);
phases[phase.ToString()] = new Dictionary<string, object?>
{
["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()
};

/// <summary>Best-effort hardware serial number from the SMBIOS registry key.</summary>
private static string? SerialNumber()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\BIOS");
return key?.GetValue("SystemSerialNumber") as string;
}
catch
{
return null;
}
}
}
}
27 changes: 27 additions & 0 deletions resources/BootstrapMate.admx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<category name="Advanced" displayName="$(string.Cat_Advanced)">
<parentCategory ref="BootstrapMate" />
</category>
<category name="Reporting" displayName="$(string.Cat_Reporting)">
<category name="Security" displayName="$(string.Cat_Security)">
<parentCategory ref="BootstrapMate" />
</category>
Expand Down Expand Up @@ -225,6 +226,32 @@
</elements>
</policy>

<!-- ═══ Reporting ═══ -->

<policy name="ReportingUrl"
class="Machine"
displayName="$(string.ReportingUrl)"
explainText="$(string.ReportingUrl_Help)"
key="SOFTWARE\Policies\BootstrapMate"
presentation="$(presentation.ReportingUrl)">
<parentCategory ref="Reporting" />
<supportedOn ref="windows:SUPPORTED_Windows_10_0" />
<elements>
<text id="ReportingUrl_Value" valueName="ReportingUrl" />
</elements>
</policy>

<policy name="ReportingHeader"
class="Machine"
displayName="$(string.ReportingHeader)"
explainText="$(string.ReportingHeader_Help)"
key="SOFTWARE\Policies\BootstrapMate"
presentation="$(presentation.ReportingHeader)">
<parentCategory ref="Reporting" />
<supportedOn ref="windows:SUPPORTED_Windows_10_0" />
<elements>
<text id="ReportingHeader_Value" valueName="ReportingHeader" />
</elements>
<!-- ═══ Security ═══ -->

<policy name="VerifyPackageSignatures"
Expand Down
24 changes: 24 additions & 0 deletions resources/en-US/BootstrapMate.adml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<string id="Cat_Behavior">Behavior</string>
<string id="Cat_Dialog">Dialog</string>
<string id="Cat_Advanced">Advanced</string>
<string id="Cat_Reporting">Reporting</string>
<string id="Cat_Security">Security</string>

<!-- Connection -->
Expand Down Expand Up @@ -101,6 +102,20 @@ Default: C:\Program Files\BootstrapMate</string>
Range: 10–600 seconds
Default: 120 seconds</string>

<!-- Reporting -->
<string id="ReportingUrl">Reporting URL</string>
<string id="ReportingUrl_Help">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).</string>

<string id="ReportingHeader">Reporting Authorization Header</string>
<string id="ReportingHeader_Help">Optional Authorization header value sent with the reporting POST request.

Example: Bearer eyJhbGciOiJIUzI1NiIs...

Leave empty if your reporting endpoint does not require authentication.</string>
<!-- Security -->
<string id="VerifyPackageSignatures">Verify package signatures</string>
<string id="VerifyPackageSignatures_Help">Verify the Authenticode signature of MSI/EXE installers before running them elevated. The download only proves where bytes came from, not who produced them.
Expand Down Expand Up @@ -161,6 +176,15 @@ Default: Disabled</string>
<decimalTextBox refId="NetworkTimeout_Value" defaultValue="120">Timeout (seconds):</decimalTextBox>
</presentation>

<presentation id="ReportingUrl">
<textBox refId="ReportingUrl_Value">
<label>Reporting URL:</label>
</textBox>
</presentation>

<presentation id="ReportingHeader">
<textBox refId="ReportingHeader_Value">
<label>Authorization Header:</label>
<presentation id="ExpectedPublisher">
<textBox refId="ExpectedPublisher_Value">
<label>Expected publisher:</label>
Expand Down
3 changes: 3 additions & 0 deletions src/BootstrapMate.Core/BootstrapMateConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
15 changes: 15 additions & 0 deletions src/BootstrapMate.Core/ConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/BootstrapMate.Core/ManagementDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading