From 7e0cc63ef832516f6514fcfc609ff28735becc3f Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 14 Jun 2026 23:01:30 -0700 Subject: [PATCH 1/2] Post a vendor-neutral run summary on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BootstrapMate status was local-only (plist + status.json), so checking whether a Mac provisioned cleanly meant an SSH/ARD expedition. Add an optional reporting POST: when reportingUrl is configured, a JSON run summary is sent on completion. The payload is intentionally backend-agnostic (not ReportMate-specific) — any endpoint that accepts a JSON POST can consume it. It includes runId, version, success, start/end/duration, architecture, hostname, serial number, manifest URL, and per-phase outcomes (reusing the data StatusManager already persists). The POST is best-effort: failures are logged and never block or fail the run. Configurable via managed preferences (reportingUrl, reportingHeader) and the --reporting-url CLI flag. swift build and swift test pass. --- README.md | 13 ++ Sources/cli/bootstrapmate.swift | 6 +- Sources/core/Managers/ConfigManager.swift | 32 +++- Sources/core/Managers/IAOrchestrator.swift | 6 +- Sources/core/Managers/ReportManager.swift | 155 ++++++++++++++++++ .../BootstrapMateCoreTests.swift | 45 +++++ examples/config.mobileconfig | 4 + 7 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 Sources/core/Managers/ReportManager.swift diff --git a/README.md b/README.md index 2cc63d6..d743333 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,16 @@ The following files **should** be committed: ## Security All signing identities, team IDs, and notarization credentials are kept in the `.env` file or environment variables. The repository contains no hardcoded credentials. + +## Reporting + +When a run completes, BootstrapMate can POST a vendor-neutral JSON run summary to an optional endpoint, turning "did this Mac provision cleanly?" into a fleet-dashboard query. The payload is plain JSON and not tied to any specific backend — any service that accepts a JSON POST (a custom collector, ReportMate, MunkiReport, etc.) can consume it. + +Configure via managed preferences (`com.github.bootstrapmate`) or the `--reporting-url` CLI flag: + +| 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 (`preflight`/`setupassistant`/`userland` with stage, exit code, and any error). diff --git a/Sources/cli/bootstrapmate.swift b/Sources/cli/bootstrapmate.swift index 74a9920..724d69d 100644 --- a/Sources/cli/bootstrapmate.swift +++ b/Sources/cli/bootstrapmate.swift @@ -55,6 +55,9 @@ struct BootstrapMate: ParsableCommand { @Option(name: .long, help: "Maximum seconds to wait for network (default: 120).") var networkTimeout: Int = 120 + @Option(name: .long, help: "URL to POST a run summary to on completion (vendor-neutral JSON).") + var reportingUrl: String? + /// Thread-safe wrapper for network status private final class NetworkStatus: @unchecked Sendable { var isReady = false @@ -224,7 +227,8 @@ struct BootstrapMate: ParsableCommand { reboot: reboot, userscriptOnly: userscript, silentMode: silent, - verboseMode: verbose + verboseMode: verbose, + reportingUrl: reportingUrl ) // Debug: Show effective configuration diff --git a/Sources/core/Managers/ConfigManager.swift b/Sources/core/Managers/ConfigManager.swift index afce143..25d099c 100644 --- a/Sources/core/Managers/ConfigManager.swift +++ b/Sources/core/Managers/ConfigManager.swift @@ -23,6 +23,9 @@ public struct BootstrapMateConfig { public var customInstallPath: String? public var daemonIdentifier: String public var agentIdentifier: String + // Reporting: vendor-neutral run-summary POST + public var reportingUrl: String? + public var reportingHeader: String? // Dialog / UI settings public var enableDialog: Bool public var dialogTitle: String @@ -43,6 +46,8 @@ public struct BootstrapMateConfig { customInstallPath: String? = nil, daemonIdentifier: String = BootstrapMateConstants.daemonIdentifier, agentIdentifier: String = BootstrapMateConstants.daemonIdentifier, + reportingUrl: String? = nil, + reportingHeader: String? = nil, enableDialog: Bool = true, dialogTitle: String = "Setting up your Mac", dialogMessage: String = "Please wait while we configure your device...", @@ -61,6 +66,8 @@ public struct BootstrapMateConfig { self.customInstallPath = customInstallPath self.daemonIdentifier = daemonIdentifier self.agentIdentifier = agentIdentifier + self.reportingUrl = reportingUrl + self.reportingHeader = reportingHeader self.enableDialog = enableDialog self.dialogTitle = dialogTitle self.dialogMessage = dialogMessage @@ -107,7 +114,8 @@ public final class ConfigManager { reboot: Bool? = nil, userscriptOnly: Bool? = nil, silentMode: Bool? = nil, - verboseMode: Bool? = nil + verboseMode: Bool? = nil, + reportingUrl: String? = nil ) { if let url = jsonUrl, !url.isEmpty { config.jsonUrl = url @@ -148,6 +156,11 @@ public final class ConfigManager { config.verboseMode = verbose Logger.debug("CLI override: verboseMode = \(verbose)") } + + if let reporting = reportingUrl, !reporting.isEmpty { + config.reportingUrl = reporting + Logger.debug("CLI override: reportingUrl = \(reporting)") + } } /// Get the effective JSON URL (from config or fallback) @@ -348,6 +361,22 @@ public final class ConfigManager { } else if let value = CFPreferencesCopyAppValue("laidentifier" as CFString, cfDomain) as? String { config.agentIdentifier = value } + + // Reporting: vendor-neutral run-summary POST endpoint + let reportingUrlKeys = ["reportingUrl", "ReportingUrl", "ReportURL", "reportingURL"] + for key in reportingUrlKeys { + if let value = CFPreferencesCopyAppValue(key as CFString, cfDomain) as? String, !value.isEmpty { + config.reportingUrl = value + break + } + } + let reportingHeaderKeys = ["reportingHeader", "ReportingHeader", "ReportingAuthorizationHeader"] + for key in reportingHeaderKeys { + if let value = CFPreferencesCopyAppValue(key as CFString, cfDomain) as? String, !value.isEmpty { + config.reportingHeader = value + break + } + } // Dialog / UI settings if let value = CFPreferencesCopyAppValue("enableDialog" as CFString, cfDomain) as? Bool { @@ -431,6 +460,7 @@ public final class ConfigManager { Logger.debug(" silentMode: \(config.silentMode)") Logger.debug(" verboseMode: \(config.verboseMode)") Logger.debug(" installPath: \(getInstallPath())") + Logger.debug(" reportingUrl: \(config.reportingUrl ?? "not set")") Logger.debug(" daemonIdentifier: \(config.daemonIdentifier)") Logger.debug(" enableDialog: \(config.enableDialog)") Logger.debug(" dialogTitle: \(config.dialogTitle)") diff --git a/Sources/core/Managers/IAOrchestrator.swift b/Sources/core/Managers/IAOrchestrator.swift index 127c9f8..d9258b7 100644 --- a/Sources/core/Managers/IAOrchestrator.swift +++ b/Sources/core/Managers/IAOrchestrator.swift @@ -121,7 +121,11 @@ public final class IAOrchestrator { // Close dialog DialogManager.shared.close() - + + // Post a vendor-neutral run summary to the optional reporting endpoint + // before cleanup boots us out. Best-effort; never blocks completion. + ReportManager.shared.sendRunSummary(success: success, startTime: startTime) + // Handle reboot if reboot && success { Logger.info("Reboot requested, triggering in 5 seconds...") diff --git a/Sources/core/Managers/ReportManager.swift b/Sources/core/Managers/ReportManager.swift new file mode 100644 index 0000000..0c3be26 --- /dev/null +++ b/Sources/core/Managers/ReportManager.swift @@ -0,0 +1,155 @@ +// +// ReportManager.swift +// BootstrapMate +// +// Posts a vendor-neutral run summary to an optional reporting endpoint when a +// bootstrap run completes. This turns "did this Mac provision cleanly?" into a +// fleet-dashboard query instead of an SSH/ARD expedition. +// +// The payload is plain JSON and intentionally NOT specific to any one backend +// (ReportMate, MunkiReport, a custom collector, …) — any service that accepts a +// JSON POST can consume it. +// + +import Foundation +import IOKit + +public final class ReportManager { + nonisolated(unsafe) public static let shared = ReportManager() + + private init() {} + + /// Build and POST the run summary to `config.reportingUrl`, if configured. + /// Best-effort: failures are logged and never abort the run. + public func sendRunSummary(success: Bool, startTime: Date, endTime: Date = Date()) { + let config = ConfigManager.shared.config + guard let urlString = config.reportingUrl, !urlString.isEmpty, + let url = URL(string: urlString) else { + return + } + + let payload = Self.buildPayload( + success: success, + startTime: startTime, + endTime: endTime, + version: BootstrapMateConstants.version, + runId: StatusManager.shared.getCurrentRunId(), + manifestUrl: config.jsonUrl ?? "", + phases: collectPhases() + ) + + guard let body = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { + Logger.warning("Could not serialize reporting payload") + return + } + + Logger.info("Posting run summary to reporting endpoint: \(urlString)") + postJSON(body, to: url, authHeader: config.reportingHeader) + } + + /// Assemble the vendor-neutral summary dictionary. Pure function for testing. + public static func buildPayload( + success: Bool, + startTime: Date, + endTime: Date, + version: String, + runId: String, + manifestUrl: String, + phases: [String: [String: Any]] + ) -> [String: Any] { + let iso = ISO8601DateFormatter() + return [ + "tool": "BootstrapMate", + "platform": "macOS", + "schemaVersion": 1, + "version": version, + "runId": runId, + "success": success, + "startTime": iso.string(from: startTime), + "endTime": iso.string(from: endTime), + "durationSeconds": Int(endTime.timeIntervalSince(startTime).rounded()), + "architecture": currentArchitecture(), + "hostname": ProcessInfo.processInfo.hostName, + "serialNumber": serialNumber() ?? "", + "manifestUrl": manifestUrl, + "phases": phases + ] + } + + // MARK: - Private + + /// Read the persisted per-phase status and flatten it for the report. + private func collectPhases() -> [String: [String: Any]] { + var phases: [String: [String: Any]] = [:] + for phase in [InstallationPhase.preflight, .setupAssistant, .userland] { + guard let status = StatusManager.shared.getPhaseStatus(phase: phase) else { continue } + phases[phase.rawValue] = [ + "stage": status.stage.rawValue, + "exitCode": status.exitCode, + "startTime": status.startTime, + "completionTime": status.completionTime, + "lastError": status.lastError + ] + } + return phases + } + + private func postJSON(_ body: Data, to url: URL, authHeader: String?) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("BootstrapMate/\(BootstrapMateConstants.version)", forHTTPHeaderField: "User-Agent") + if let authHeader = authHeader, !authHeader.isEmpty { + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + request.httpBody = body + request.timeoutInterval = 30 + + let semaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: request) { _, response, error in + defer { semaphore.signal() } + if let error = error { + Logger.warning("Reporting POST failed: \(error.localizedDescription)") + return + } + if let http = response as? HTTPURLResponse { + if (200...299).contains(http.statusCode) { + Logger.info("Run summary reported (HTTP \(http.statusCode))") + } else { + Logger.warning("Reporting endpoint returned HTTP \(http.statusCode)") + } + } + } + task.resume() + _ = semaphore.wait(timeout: .now() + 35) + } + + private static func currentArchitecture() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let chars = machineMirror.children.compactMap { $0.value as? Int8 } + .filter { $0 != 0 } + .map { Character(UnicodeScalar(UInt8($0))) } + return String(chars).contains("arm64") ? "ARM64" : "X64" + } + + /// Best-effort hardware serial number via IOKit. + private static func serialNumber() -> String? { + let platformExpert = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching("IOPlatformExpertDevice") + ) + guard platformExpert != 0 else { return nil } + defer { IOObjectRelease(platformExpert) } + guard let cf = IORegistryEntryCreateCFProperty( + platformExpert, + kIOPlatformSerialNumberKey as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String, !cf.isEmpty else { + return nil + } + return cf + } +} diff --git a/Tests/BootstrapMateCoreTests/BootstrapMateCoreTests.swift b/Tests/BootstrapMateCoreTests/BootstrapMateCoreTests.swift index d5ab087..629904c 100644 --- a/Tests/BootstrapMateCoreTests/BootstrapMateCoreTests.swift +++ b/Tests/BootstrapMateCoreTests/BootstrapMateCoreTests.swift @@ -10,6 +10,51 @@ struct BootstrapMateCoreTests { } } +// MARK: - ReportManager Tests + +@Suite("ReportManager Tests") +struct ReportManagerTests { + + @Test("Payload contains the core run-summary fields") + func payloadShape() { + let start = Date(timeIntervalSince1970: 1_000_000) + let end = Date(timeIntervalSince1970: 1_000_042) + let payload = ReportManager.buildPayload( + success: true, + startTime: start, + endTime: end, + version: "2026.06.14.1200", + runId: "test-run-id", + manifestUrl: "https://example.com/manifest.json", + phases: ["Userland": ["stage": "Completed", "exitCode": 0]] + ) + + #expect(payload["tool"] as? String == "BootstrapMate") + #expect(payload["platform"] as? String == "macOS") + #expect(payload["success"] as? Bool == true) + #expect(payload["runId"] as? String == "test-run-id") + #expect(payload["version"] as? String == "2026.06.14.1200") + #expect(payload["durationSeconds"] as? Int == 42) + #expect(payload["manifestUrl"] as? String == "https://example.com/manifest.json") + #expect(payload["phases"] != nil) + } + + @Test("Payload serializes to JSON") + func payloadSerializes() throws { + let payload = ReportManager.buildPayload( + success: false, + startTime: Date(timeIntervalSince1970: 0), + endTime: Date(timeIntervalSince1970: 5), + version: "v", + runId: "r", + manifestUrl: "", + phases: [:] + ) + let data = try JSONSerialization.data(withJSONObject: payload) + #expect(data.isEmpty == false) + } +} + // MARK: - ManifestDecoder Tests @Suite("ManifestDecoder Tests") diff --git a/examples/config.mobileconfig b/examples/config.mobileconfig index 251ab1a..1925da4 100644 --- a/examples/config.mobileconfig +++ b/examples/config.mobileconfig @@ -21,6 +21,10 @@ reboot + reportingUrl + https://example.com/bootstrap/report + reportingHeader + Bearer YOUR_REPORTING_TOKEN_HERE PayloadDescription Configures BootstrapMate with all available settings. Remove unused keys or set to desired values. From adbdee4792ee34e74195a7c57f32714998366aeb Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 15 Jun 2026 16:52:35 -0700 Subject: [PATCH 2/2] Address review: reporting accuracy and log hygiene - Redact the reporting endpoint in logs (scheme/host/path only) and stop logging the raw reportingUrl in config debug output, so tokens embedded in the URL never reach the logs. - Normalize per-phase timestamps to ISO-8601 so the whole payload uses one timestamp format (falls back to the original string if unparseable). - Correct docs/comments: the POST is bounded by a short timeout (now 15s) rather than 'never blocking', and document the actual per-phase payload keys (Preflight/SetupAssistant/Userland). --- README.md | 2 +- Sources/core/Managers/ConfigManager.swift | 4 +-- Sources/core/Managers/IAOrchestrator.swift | 3 +- Sources/core/Managers/ReportManager.swift | 40 ++++++++++++++++++---- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d743333..fc43064 100644 --- a/README.md +++ b/README.md @@ -172,4 +172,4 @@ Configure via managed preferences (`com.github.bootstrapmate`) or the `--reporti | `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 (`preflight`/`setupassistant`/`userland` with stage, exit code, and any error). +The POST is best-effort: it is bounded by a short timeout and never fails the run. Payload fields include `tool`, `platform`, `version`, `runId`, `success`, `startTime`/`endTime`, `durationSeconds`, `architecture`, `hostname`, `serialNumber`, `manifestUrl`, and per-phase outcomes (keyed `Preflight`/`SetupAssistant`/`Userland`, each with stage, exit code, and any error). diff --git a/Sources/core/Managers/ConfigManager.swift b/Sources/core/Managers/ConfigManager.swift index 25d099c..5b52ed7 100644 --- a/Sources/core/Managers/ConfigManager.swift +++ b/Sources/core/Managers/ConfigManager.swift @@ -159,7 +159,7 @@ public final class ConfigManager { if let reporting = reportingUrl, !reporting.isEmpty { config.reportingUrl = reporting - Logger.debug("CLI override: reportingUrl = \(reporting)") + Logger.debug("CLI override: reportingUrl set") } } @@ -460,7 +460,7 @@ public final class ConfigManager { Logger.debug(" silentMode: \(config.silentMode)") Logger.debug(" verboseMode: \(config.verboseMode)") Logger.debug(" installPath: \(getInstallPath())") - Logger.debug(" reportingUrl: \(config.reportingUrl ?? "not set")") + Logger.debug(" reportingUrl: \(config.reportingUrl != nil ? "set" : "not set")") Logger.debug(" daemonIdentifier: \(config.daemonIdentifier)") Logger.debug(" enableDialog: \(config.enableDialog)") Logger.debug(" dialogTitle: \(config.dialogTitle)") diff --git a/Sources/core/Managers/IAOrchestrator.swift b/Sources/core/Managers/IAOrchestrator.swift index d9258b7..ca51b27 100644 --- a/Sources/core/Managers/IAOrchestrator.swift +++ b/Sources/core/Managers/IAOrchestrator.swift @@ -123,7 +123,8 @@ public final class IAOrchestrator { DialogManager.shared.close() // Post a vendor-neutral run summary to the optional reporting endpoint - // before cleanup boots us out. Best-effort; never blocks completion. + // before cleanup boots us out. Best-effort and bounded by a short + // timeout; a failure is logged but never fails the run. ReportManager.shared.sendRunSummary(success: success, startTime: startTime) // Handle reboot diff --git a/Sources/core/Managers/ReportManager.swift b/Sources/core/Managers/ReportManager.swift index 0c3be26..d75b0a4 100644 --- a/Sources/core/Managers/ReportManager.swift +++ b/Sources/core/Managers/ReportManager.swift @@ -20,7 +20,8 @@ public final class ReportManager { private init() {} /// Build and POST the run summary to `config.reportingUrl`, if configured. - /// Best-effort: failures are logged and never abort the run. + /// Best-effort: the POST is bounded by a short timeout, and any failure is + /// logged but never fails the run. public func sendRunSummary(success: Bool, startTime: Date, endTime: Date = Date()) { let config = ConfigManager.shared.config guard let urlString = config.reportingUrl, !urlString.isEmpty, @@ -43,10 +44,20 @@ public final class ReportManager { return } - Logger.info("Posting run summary to reporting endpoint: \(urlString)") + Logger.info("Posting run summary to reporting endpoint: \(Self.redactedEndpoint(url))") postJSON(body, to: url, authHeader: config.reportingHeader) } + /// Scheme + host + path only — drops userinfo and query so tokens that may + /// be embedded in the URL never reach the logs. + private static func redactedEndpoint(_ url: URL) -> String { + var parts = "" + if let scheme = url.scheme { parts += "\(scheme)://" } + parts += url.host ?? "" + parts += url.path + return parts.isEmpty ? "configured endpoint" : parts + } + /// Assemble the vendor-neutral summary dictionary. Pure function for testing. public static func buildPayload( success: Bool, @@ -86,14 +97,29 @@ public final class ReportManager { phases[phase.rawValue] = [ "stage": status.stage.rawValue, "exitCode": status.exitCode, - "startTime": status.startTime, - "completionTime": status.completionTime, + "startTime": Self.isoFromStatusTimestamp(status.startTime), + "completionTime": Self.isoFromStatusTimestamp(status.completionTime), "lastError": status.lastError ] } return phases } + /// StatusManager persists timestamps as "yyyy-MM-dd HH:mm:ss"; re-emit them + /// as ISO-8601 so the whole payload uses one timestamp format. Falls back to + /// the original string if it can't be parsed (e.g. empty). + private static let statusDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static func isoFromStatusTimestamp(_ value: String) -> String { + guard !value.isEmpty, let date = statusDateFormatter.date(from: value) else { return value } + return ISO8601DateFormatter().string(from: date) + } + private func postJSON(_ body: Data, to url: URL, authHeader: String?) { var request = URLRequest(url: url) request.httpMethod = "POST" @@ -103,7 +129,7 @@ public final class ReportManager { request.setValue(authHeader, forHTTPHeaderField: "Authorization") } request.httpBody = body - request.timeoutInterval = 30 + request.timeoutInterval = 15 let semaphore = DispatchSemaphore(value: 0) let task = URLSession.shared.dataTask(with: request) { _, response, error in @@ -121,7 +147,9 @@ public final class ReportManager { } } task.resume() - _ = semaphore.wait(timeout: .now() + 35) + // Bounded wait: the CLI exits right after this returns, so we must let + // the POST finish — but never linger beyond the request timeout. + _ = semaphore.wait(timeout: .now() + 20) } private static func currentArchitecture() -> String {