← back to Exo
Use presigned URLs for bug report uploads (#1109)
4759b09d4c81ceabed89a8ae1b4c8903a0e29a01 · 2026-01-08 17:17:48 +0000 · Alex Cheema
## Motivation
Previously we hardcoded AWS credentials into the app.
This is not good practice.
## Changes
Use presigned URLs instead.
## Why It Works
Presigned URLs are an S3 feature for this kind of thing. They provide an
expiring presigned URL with certain permissions. In this case we have a
presigned URL with `s3:PutObject` permission that expires after 5
minutes. The client uses this presigned URL to upload a bug report
instead of using its own credentials to sign a request. This also
simplifies a lot of the Swift code.
## Test Plan
### Manual Testing
On a single MacBook, I downloaded the app and sent a bug report. It
worked and appeared in the bucket.
Files touched
M .github/workflows/build-app.ymlM app/EXO/EXO/Info.plistM app/EXO/EXO/Services/BugReportService.swift
Diff
commit 4759b09d4c81ceabed89a8ae1b4c8903a0e29a01
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Thu Jan 8 17:17:48 2026 +0000
Use presigned URLs for bug report uploads (#1109)
## Motivation
Previously we hardcoded AWS credentials into the app.
This is not good practice.
## Changes
Use presigned URLs instead.
## Why It Works
Presigned URLs are an S3 feature for this kind of thing. They provide an
expiring presigned URL with certain permissions. In this case we have a
presigned URL with `s3:PutObject` permission that expires after 5
minutes. The client uses this presigned URL to upload a bug report
instead of using its own credentials to sign a request. This also
simplifies a lot of the Swift code.
## Test Plan
### Manual Testing
On a single MacBook, I downloaded the app and sent a bug report. It
worked and appeared in the bucket.
---
.github/workflows/build-app.yml | 2 +
app/EXO/EXO/Info.plist | 2 +
app/EXO/EXO/Services/BugReportService.swift | 303 ++++++++++------------------
3 files changed, 114 insertions(+), 193 deletions(-)
diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml
index c5721fa9..6351f89f 100644
--- a/.github/workflows/build-app.yml
+++ b/.github/workflows/build-app.yml
@@ -18,6 +18,7 @@ jobs:
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
SPARKLE_S3_BUCKET: ${{ secrets.SPARKLE_S3_BUCKET }}
SPARKLE_S3_PREFIX: ${{ secrets.SPARKLE_S3_PREFIX }}
+ EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT: ${{ secrets.EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT }}
AWS_REGION: ${{ secrets.AWS_REGION }}
EXO_BUILD_NUMBER: ${{ github.run_number }}
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
@@ -167,6 +168,7 @@ jobs:
EXO_BUILD_COMMIT="$GITHUB_SHA" \
SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \
SPARKLE_ED25519_PUBLIC="$SPARKLE_ED25519_PUBLIC" \
+ EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT="$EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT" \
CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES
mkdir -p ../../output
diff --git a/app/EXO/EXO/Info.plist b/app/EXO/EXO/Info.plist
index df4073e5..575170a6 100644
--- a/app/EXO/EXO/Info.plist
+++ b/app/EXO/EXO/Info.plist
@@ -8,6 +8,8 @@
<string>$(EXO_BUILD_TAG)</string>
<key>EXOBuildCommit</key>
<string>$(EXO_BUILD_COMMIT)</string>
+ <key>EXOBugReportPresignedUrlEndpoint</key>
+ <string>$(EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT)</string>
<key>NSLocalNetworkUsageDescription</key>
<string>EXO needs local network access to discover and connect to other devices in your cluster for distributed AI inference.</string>
<key>NSBonjourServices</key>
diff --git a/app/EXO/EXO/Services/BugReportService.swift b/app/EXO/EXO/Services/BugReportService.swift
index 4cd4639d..70180855 100644
--- a/app/EXO/EXO/Services/BugReportService.swift
+++ b/app/EXO/EXO/Services/BugReportService.swift
@@ -1,4 +1,3 @@
-import CryptoKit
import Foundation
struct BugReportOutcome: Equatable {
@@ -7,17 +6,17 @@ struct BugReportOutcome: Equatable {
}
enum BugReportError: LocalizedError {
- case missingCredentials
case invalidEndpoint
+ case presignedUrlFailed(String)
case uploadFailed(String)
case collectFailed(String)
var errorDescription: String? {
switch self {
- case .missingCredentials:
- return "Bug report upload credentials are not set."
case .invalidEndpoint:
return "Bug report endpoint is invalid."
+ case .presignedUrlFailed(let message):
+ return "Failed to get presigned URLs: \(message)"
case .uploadFailed(let message):
return "Bug report upload failed: \(message)"
case .collectFailed(let message):
@@ -27,11 +26,13 @@ enum BugReportError: LocalizedError {
}
struct BugReportService {
- struct AWSConfig {
- let accessKey: String
- let secretKey: String
- let region: String
- let bucket: String
+ private struct PresignedUrlsRequest: Codable {
+ let keys: [String]
+ }
+
+ private struct PresignedUrlsResponse: Codable {
+ let urls: [String: String]
+ let expiresIn: Int?
}
func sendReport(
@@ -39,9 +40,9 @@ struct BugReportService {
now: Date = Date(),
isManual: Bool = false
) async throws -> BugReportOutcome {
- let credentials = try loadCredentials()
- let timestamp = ISO8601DateFormatter().string(from: now)
- let prefix = "reports/\(timestamp)/"
+ let timestamp = Self.runTimestampString(now)
+ let dayPrefix = Self.dayPrefixString(now)
+ let prefix = "reports/\(dayPrefix)/\(timestamp)/"
let logData = readLog()
let ifconfigText = try await captureIfconfig()
@@ -69,26 +70,79 @@ struct BugReportService {
("\(prefix)report.json", reportJSON),
]
- let uploader = try S3Uploader(config: credentials)
- for item in uploads {
- guard let data = item.data else { continue }
- try await uploader.upload(
- objectPath: item.path,
- body: data
- )
+ let uploadItems: [(key: String, body: Data)] = uploads.compactMap { item in
+ guard let body = item.data else { return nil }
+ return (key: item.path, body: body)
+ }
+
+ guard !uploadItems.isEmpty else {
+ return BugReportOutcome(success: false, message: "No data to upload")
+ }
+
+ let presignedUrls = try await fetchPresignedUploadUrls(keys: uploadItems.map(\.key))
+ for item in uploadItems {
+ guard let urlString = presignedUrls[item.key], let url = URL(string: urlString) else {
+ throw BugReportError.uploadFailed("Missing presigned URL for \(item.key)")
+ }
+ try await uploadToPresignedUrl(url: url, body: item.body)
}
return BugReportOutcome(
success: true, message: "Bug Report sent. Thank you for helping to improve EXO 1.0.")
}
- private func loadCredentials() throws -> AWSConfig {
- return AWSConfig(
- accessKey: "AKIAYEKP5EMXTOBYDGHX",
- secretKey: "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",
- region: "us-east-1",
- bucket: "exo-bug-reports"
- )
+ private static func dayPrefixString(_ date: Date) -> String {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current
+ let components = calendar.dateComponents([.year, .month, .day], from: date)
+ let year = components.year ?? 0
+ let month = components.month ?? 0
+ let day = components.day ?? 0
+ return String(format: "%04d/%02d/%02d", year, month, day)
+ }
+
+ private static func runTimestampString(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(secondsFromGMT: 0) ?? .current
+ formatter.dateFormat = "yyyy-MM-dd'T'HHmmss.SSS'Z'"
+ return formatter.string(from: date)
+ }
+
+ private func fetchPresignedUploadUrls(keys: [String], bundle: Bundle = .main) async throws
+ -> [String: String]
+ {
+ guard
+ let endpointString = bundle.infoDictionary?["EXOBugReportPresignedUrlEndpoint"]
+ as? String
+ else {
+ throw BugReportError.invalidEndpoint
+ }
+ let trimmedEndpointString = endpointString.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedEndpointString.isEmpty, let endpoint = URL(string: trimmedEndpointString)
+ else {
+ throw BugReportError.invalidEndpoint
+ }
+
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 10
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let encoder = JSONEncoder()
+ request.httpBody = try encoder.encode(PresignedUrlsRequest(keys: keys))
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw BugReportError.presignedUrlFailed("Non-HTTP response")
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw BugReportError.presignedUrlFailed("HTTP status \(http.statusCode)")
+ }
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode(PresignedUrlsResponse.self, from: data)
+ return decoded.urls
}
private func readLog() -> Data? {
@@ -179,6 +233,36 @@ struct BugReportService {
}
}
+ private func uploadToPresignedUrl(url: URL, body: Data) async throws {
+ let maxAttempts = 2
+ var lastError: Error?
+
+ for attempt in 1...maxAttempts {
+ do {
+ var request = URLRequest(url: url)
+ request.httpMethod = "PUT"
+ request.httpBody = body
+ request.timeoutInterval = 30
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw BugReportError.uploadFailed("Non-HTTP response")
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw BugReportError.uploadFailed("HTTP status \(http.statusCode)")
+ }
+ return
+ } catch {
+ lastError = error
+ if attempt < maxAttempts {
+ try await Task.sleep(nanoseconds: 400_000_000)
+ }
+ }
+ }
+
+ throw BugReportError.uploadFailed(lastError?.localizedDescription ?? "Unknown error")
+ }
+
private func makeReportJson(
timestamp: String,
hostName: String,
@@ -411,170 +495,3 @@ private struct CommandResult {
let output: String
let error: String
}
-
-private struct S3Uploader {
- let config: BugReportService.AWSConfig
-
- init(config: BugReportService.AWSConfig) throws {
- self.config = config
- }
-
- func upload(objectPath: String, body: Data) async throws {
- let host = "\(config.bucket).s3.amazonaws.com"
- guard let url = URL(string: "https://\(host)/\(objectPath)") else {
- throw BugReportError.invalidEndpoint
- }
-
- let now = Date()
- let amzDate = awsTimestamp(now)
- let dateStamp = dateStamp(now)
- let payloadHash = sha256Hex(body)
-
- let headers = [
- "host": host,
- "x-amz-content-sha256": payloadHash,
- "x-amz-date": amzDate,
- ]
-
- let canonicalRequest = buildCanonicalRequest(
- method: "PUT",
- url: url,
- headers: headers,
- payloadHash: payloadHash
- )
-
- let stringToSign = buildStringToSign(
- amzDate: amzDate,
- dateStamp: dateStamp,
- canonicalRequestHash: sha256Hex(canonicalRequest.data(using: .utf8) ?? Data())
- )
-
- let signingKey = deriveKey(
- secret: config.secretKey, dateStamp: dateStamp, region: config.region, service: "s3")
- let signature = hmacHex(key: signingKey, data: Data(stringToSign.utf8))
-
- let signedHeaders = "host;x-amz-content-sha256;x-amz-date"
- let authorization = """
- AWS4-HMAC-SHA256 Credential=\(config.accessKey)/\(dateStamp)/\(config.region)/s3/aws4_request, SignedHeaders=\(signedHeaders), Signature=\(signature)
- """
-
- var request = URLRequest(url: url)
- request.httpMethod = "PUT"
- request.httpBody = body
- request.setValue(
- headers["x-amz-content-sha256"], forHTTPHeaderField: "x-amz-content-sha256")
- request.setValue(headers["x-amz-date"], forHTTPHeaderField: "x-amz-date")
- request.setValue(host, forHTTPHeaderField: "Host")
- request.setValue(authorization, forHTTPHeaderField: "Authorization")
-
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- let statusText = (response as? HTTPURLResponse)?.statusCode ?? -1
- _ = data // ignore response body for UX
- throw BugReportError.uploadFailed("HTTP status \(statusText)")
- }
- }
-
- private func buildCanonicalRequest(
- method: String,
- url: URL,
- headers: [String: String],
- payloadHash: String
- ) -> String {
- let canonicalURI = encodePath(url.path)
- let canonicalQuery = url.query ?? ""
- let sortedHeaders = headers.sorted { $0.key < $1.key }
- let canonicalHeaders =
- sortedHeaders
- .map { "\($0.key.lowercased()):\($0.value)\n" }
- .joined()
- let signedHeaders = sortedHeaders.map { $0.key.lowercased() }.joined(separator: ";")
-
- return [
- method,
- canonicalURI,
- canonicalQuery,
- canonicalHeaders,
- signedHeaders,
- payloadHash,
- ].joined(separator: "\n")
- }
-
- private func encodePath(_ path: String) -> String {
- return
- path
- .split(separator: "/")
- .map { segment in
- segment.addingPercentEncoding(withAllowedCharacters: Self.rfc3986)
- ?? String(segment)
- }
- .joined(separator: "/")
- .prependSlashIfNeeded()
- }
-
- private func buildStringToSign(
- amzDate: String,
- dateStamp: String,
- canonicalRequestHash: String
- ) -> String {
- """
- AWS4-HMAC-SHA256
- \(amzDate)
- \(dateStamp)/\(config.region)/s3/aws4_request
- \(canonicalRequestHash)
- """
- }
-
- private func deriveKey(secret: String, dateStamp: String, region: String, service: String)
- -> Data
- {
- let kDate = hmac(key: Data(("AWS4" + secret).utf8), data: Data(dateStamp.utf8))
- let kRegion = hmac(key: kDate, data: Data(region.utf8))
- let kService = hmac(key: kRegion, data: Data(service.utf8))
- return hmac(key: kService, data: Data("aws4_request".utf8))
- }
-
- private func hmac(key: Data, data: Data) -> Data {
- let keySym = SymmetricKey(data: key)
- let mac = HMAC<SHA256>.authenticationCode(for: data, using: keySym)
- return Data(mac)
- }
-
- private func hmacHex(key: Data, data: Data) -> String {
- hmac(key: key, data: data).map { String(format: "%02x", $0) }.joined()
- }
-
- private func sha256Hex(_ data: Data) -> String {
- let digest = SHA256.hash(data: data)
- return digest.compactMap { String(format: "%02x", $0) }.joined()
- }
-
- private func awsTimestamp(_ date: Date) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
- formatter.timeZone = TimeZone(abbreviation: "UTC")
- return formatter.string(from: date)
- }
-
- private func dateStamp(_ date: Date) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyyMMdd"
- formatter.timeZone = TimeZone(abbreviation: "UTC")
- return formatter.string(from: date)
- }
-
- private static let rfc3986: CharacterSet = {
- var set = CharacterSet.alphanumerics
- set.insert(charactersIn: "-._~")
- return set
- }()
-}
-
-extension String {
- fileprivate func prependSlashIfNeeded() -> String {
- if hasPrefix("/") {
- return self
- }
- return "/" + self
- }
-}
← ca680185 Display RDMA debug info in macOS app. (#1072)
·
back to Exo
·
ci: avoid uploading alpha appcasts 8f7f0e89 →