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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,6 @@ notarization-*.json
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.com.apple.timemachine.donotpresent

cmux.json
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# bootstrapmate-macintosh

## Worktrees

Create development worktrees **inside this repo** at `./.worktrees/<name>` — never as sibling `<repo>.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 <name> [branch]
```

That runs `git worktree add .worktrees/<name> [branch]`. Keep `/.worktrees/` listed in this repo's `.gitignore`.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/cli/bootstrapmate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions Sources/core/Managers/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)")
Expand Down
7 changes: 6 additions & 1 deletion Sources/core/Managers/IAOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand Down
183 changes: 183 additions & 0 deletions Sources/core/Managers/ReportManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
45 changes: 45 additions & 0 deletions Tests/BootstrapMateCoreTests/BootstrapMateCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions examples/config.mobileconfig
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
<false/>
<key>reboot</key>
<false/>
<key>reportingUrl</key>
<string>https://example.com/bootstrap/report</string>
<key>reportingHeader</key>
<string>Bearer YOUR_REPORTING_TOKEN_HERE</string>
<key>verifyPackageSignatures</key>
<true/>
<key>expectedTeamID</key>
Expand Down
Loading