diff --git a/.gitignore b/.gitignore index ebcb7c1..b29aee5 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,6 @@ notarization-*.json .TemporaryItems .Trashes .VolumeIcon.icns -.com.apple.timemachine.donotpresent \ No newline at end of file +.com.apple.timemachine.donotpresent + +cmux.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7ea715e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,11 @@ +# bootstrapmate-macintosh + +## Worktrees + +Create development worktrees **inside this repo** at `./.worktrees/` — never as sibling `.worktree` folders next to it. Use the global `git wt` alias, which resolves the repo root and places the worktree under `.worktrees/` from any subdirectory: + +```bash +git wt [branch] +``` + +That runs `git worktree add .worktrees/ [branch]`. Keep `/.worktrees/` listed in this repo's `.gitignore`. diff --git a/README.md b/README.md index 4ace60f..3d34312 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,18 @@ The following files **should** be committed: 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: 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). ### Package signature verification Before any installer package is handed to `/usr/sbin/installer` (which runs as root), BootstrapMate verifies its code-signing provenance with `pkgutil --check-signature`. The manifest SHA-256 only proves a download matches the manifest — it does not prove the manifest itself is authentic. The signature gate ensures a package was produced by a trusted Apple Developer ID before it executes. diff --git a/Sources/cli/bootstrapmate.swift b/Sources/cli/bootstrapmate.swift index b81d38b..8485067 100644 --- a/Sources/cli/bootstrapmate.swift +++ b/Sources/cli/bootstrapmate.swift @@ -55,6 +55,8 @@ 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? @Flag(name: .long, help: "Skip installer-package signature verification (NOT recommended).") var noVerifySignature: Bool = false @@ -234,6 +236,7 @@ struct BootstrapMate: ParsableCommand { userscriptOnly: userscript, silentMode: silent, verboseMode: verbose, + reportingUrl: reportingUrl, verifyPackageSignatures: noVerifySignature ? false : nil, expectedTeamID: expectedTeamId, allowUnsigned: allowUnsigned ? true : nil diff --git a/Sources/core/Managers/ConfigManager.swift b/Sources/core/Managers/ConfigManager.swift index f3a5ff5..1939e2c 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? // Security: package signature verification public var verifyPackageSignatures: Bool public var expectedTeamID: String? @@ -47,6 +50,8 @@ public struct BootstrapMateConfig { customInstallPath: String? = nil, daemonIdentifier: String = BootstrapMateConstants.daemonIdentifier, agentIdentifier: String = BootstrapMateConstants.daemonIdentifier, + reportingUrl: String? = nil, + reportingHeader: String? = nil, verifyPackageSignatures: Bool = true, expectedTeamID: String? = nil, allowUnsigned: Bool = false, @@ -68,6 +73,8 @@ public struct BootstrapMateConfig { self.customInstallPath = customInstallPath self.daemonIdentifier = daemonIdentifier self.agentIdentifier = agentIdentifier + self.reportingUrl = reportingUrl + self.reportingHeader = reportingHeader self.verifyPackageSignatures = verifyPackageSignatures self.expectedTeamID = expectedTeamID self.allowUnsigned = allowUnsigned @@ -118,6 +125,7 @@ public final class ConfigManager { userscriptOnly: Bool? = nil, silentMode: Bool? = nil, verboseMode: Bool? = nil, + reportingUrl: String? = nil, verifyPackageSignatures: Bool? = nil, expectedTeamID: String? = nil, allowUnsigned: Bool? = nil @@ -162,6 +170,11 @@ public final class ConfigManager { Logger.debug("CLI override: verboseMode = \(verbose)") } + if let reporting = reportingUrl, !reporting.isEmpty { + config.reportingUrl = reporting + Logger.debug("CLI override: reportingUrl set") + } + if let verify = verifyPackageSignatures { config.verifyPackageSignatures = verify Logger.debug("CLI override: verifyPackageSignatures = \(verify)") @@ -376,6 +389,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 + } + } // Security: package signature verification let verifyKeys = ["verifyPackageSignatures", "VerifyPackageSignatures", "verifySignatures"] @@ -482,6 +511,7 @@ public final class ConfigManager { Logger.debug(" silentMode: \(config.silentMode)") Logger.debug(" verboseMode: \(config.verboseMode)") Logger.debug(" installPath: \(getInstallPath())") + Logger.debug(" reportingUrl: \(config.reportingUrl != nil ? "set" : "not set")") Logger.debug(" verifyPackageSignatures: \(config.verifyPackageSignatures)") Logger.debug(" expectedTeamID: \(config.expectedTeamID ?? "any trusted")") Logger.debug(" allowUnsigned: \(config.allowUnsigned)") diff --git a/Sources/core/Managers/IAOrchestrator.swift b/Sources/core/Managers/IAOrchestrator.swift index e5f7737..df272c2 100644 --- a/Sources/core/Managers/IAOrchestrator.swift +++ b/Sources/core/Managers/IAOrchestrator.swift @@ -121,7 +121,12 @@ 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 and bounded by a short + // timeout; a failure is logged but never fails the run. + 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..d75b0a4 --- /dev/null +++ b/Sources/core/Managers/ReportManager.swift @@ -0,0 +1,183 @@ +// +// 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: 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, + 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: \(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, + 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": 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" + 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 = 15 + + 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() + // 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 { + 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 1a4f808..3221c21 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: - SignatureVerifier Tests @Suite("SignatureVerifier Tests") diff --git a/examples/config.mobileconfig b/examples/config.mobileconfig index b3d3af3..ac3cfa0 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 verifyPackageSignatures expectedTeamID