Skip to content
Open
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
46 changes: 46 additions & 0 deletions macos/Sources/ReverseAPI/UI/CaptureToolbar.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import SwiftUI
import AppKit
import ReverseAPIProxy
import UniformTypeIdentifiers

struct CaptureToolbar: View {
@Environment(AppState.self) private var state
Expand Down Expand Up @@ -43,6 +46,7 @@ struct CaptureToolbar: View {
SidebarSectionLabel("Actions")
trustButton
systemProxyButton
exportButton
clearButton
}

Expand Down Expand Up @@ -119,6 +123,34 @@ struct CaptureToolbar: View {
}
}

private func exportHAR() {
let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: "har") ?? .json, .json]
panel.nameFieldStringValue = "rae-\(Self.exportTimestamp()).har"
panel.canCreateDirectories = true
let response = panel.runModal()
guard response == .OK, let url = panel.url else { return }
let snapshot = state.store.flows
Task {
do {
try await Task.detached(priority: .userInitiated) {
let data = try HARExporter.export(snapshot)
try data.write(to: url, options: .atomic)
}.value
} catch {
await MainActor.run {
_ = NSAlert(error: error).runModal()
}
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

private static func exportTimestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}

private var captureButton: some View {
Button {
Task { await state.toggleCapture() }
Expand Down Expand Up @@ -161,6 +193,7 @@ struct CaptureToolbar: View {
.help(state.captureMode == .device
? "Start proxy capture and route macOS HTTP/HTTPS traffic through it"
: "Start proxy capture without changing macOS network settings")
.keyboardShortcut("r", modifiers: [.command])
}

private var trustButton: some View {
Expand Down Expand Up @@ -205,6 +238,18 @@ struct CaptureToolbar: View {
.help("Toggle macOS HTTP/HTTPS proxy for active network services")
}

private var exportButton: some View {
Button {
exportHAR()
} label: {
SidebarActionLabel(title: "Export HAR", systemImage: "square.and.arrow.up")
}
.buttonStyle(.plain)
.disabled(state.store.flows.isEmpty || state.isWorking)
.help("Export all captured flows to a .har file")
.keyboardShortcut("e", modifiers: [.command, .shift])
}

private var clearButton: some View {
Button {
state.clearFlows()
Expand All @@ -214,6 +259,7 @@ struct CaptureToolbar: View {
.buttonStyle(.plain)
.disabled(state.store.flows.isEmpty || state.isWorking)
.help("Remove captured flows from the list and local database")
.keyboardShortcut("k", modifiers: [.command])
}

private var captureTitle: String {
Expand Down
95 changes: 20 additions & 75 deletions macos/Sources/ReverseAPIProxy/CA/CAStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,31 @@ import Foundation
import Crypto
import X509
import SwiftASN1
import Security

public enum CAStoreError: Error {
case missingCertificateOnDisk
case missingPrivateKeyInKeychain
case keychainWriteFailed(OSStatus)
case keychainReadFailed(OSStatus)
case keychainDeleteFailed(OSStatus)
case missingPrivateKeyOnDisk
case invalidStoredPrivateKey
case certificateDeleteFailed(any Error)
case privateKeyDeleteFailed(any Error)
case privateKeyWriteFailed(any Error)
}

public final class CAStore: @unchecked Sendable {
public let directory: URL
public let certificateURL: URL

private let keychainService = "app.reverseapi"
private let keychainAccount = "ca.root-private-key"
private let legacyPrivateKeyURL: URL
public let privateKeyURL: URL

public init(applicationSupportURL: URL) throws {
let root = applicationSupportURL.appendingPathComponent("ReverseAPI", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: root,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
self.directory = root
self.certificateURL = root.appendingPathComponent("root.cer")
self.legacyPrivateKeyURL = root.appendingPathComponent("root-key.pem")
self.privateKeyURL = root.appendingPathComponent("root-key.pem")
}

public func loadOrCreate() throws -> RootCertificate {
Expand Down Expand Up @@ -69,86 +67,33 @@ public final class CAStore: @unchecked Sendable {
throw CAStoreError.certificateDeleteFailed(error)
}
}
if manager.fileExists(atPath: legacyPrivateKeyURL.path) {
if manager.fileExists(atPath: privateKeyURL.path) {
do {
try manager.removeItem(at: legacyPrivateKeyURL)
try manager.removeItem(at: privateKeyURL)
} catch {
throw CAStoreError.privateKeyDeleteFailed(error)
}
}
try deletePrivateKey()
}

public func exists() -> Bool {
guard FileManager.default.fileExists(atPath: certificateURL.path) else { return false }
return privateKeyExists()
return FileManager.default.fileExists(atPath: privateKeyURL.path)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: exists() now ignores previously keychain-stored CA keys, so upgrades can silently rotate the root CA instead of loading the existing one.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At macos/Sources/ReverseAPIProxy/CA/CAStore.swift, line 81:

<comment>`exists()` now ignores previously keychain-stored CA keys, so upgrades can silently rotate the root CA instead of loading the existing one.</comment>

<file context>
@@ -69,86 +67,33 @@ public final class CAStore: @unchecked Sendable {
     public func exists() -> Bool {
         guard FileManager.default.fileExists(atPath: certificateURL.path) else { return false }
-        return privateKeyExists()
+        return FileManager.default.fileExists(atPath: privateKeyURL.path)
     }
 
</file context>

}

private func storePrivateKeyPEM(_ data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
]

var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard updateStatus == errSecSuccess else { throw CAStoreError.keychainWriteFailed(updateStatus) }
return
do {
try data.write(to: privateKeyURL, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path)
} catch {
throw CAStoreError.privateKeyWriteFailed(error)
}
guard status == errSecSuccess else { throw CAStoreError.keychainWriteFailed(status) }
}

private func loadPrivateKeyPEM() throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound, FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path) {
let data = try Data(contentsOf: legacyPrivateKeyURL)
try storePrivateKeyPEM(data)
try? FileManager.default.removeItem(at: legacyPrivateKeyURL)
return data
}
if status == errSecItemNotFound {
throw CAStoreError.missingPrivateKeyInKeychain
}
guard status == errSecSuccess, let data = result as? Data else {
throw CAStoreError.keychainReadFailed(status)
}
return data
}

private func privateKeyExists() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: false,
kSecMatchLimit as String: kSecMatchLimitOne,
]
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess || FileManager.default.fileExists(atPath: legacyPrivateKeyURL.path)
}

private func deletePrivateKey() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: keychainAccount,
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw CAStoreError.keychainDeleteFailed(status)
guard FileManager.default.fileExists(atPath: privateKeyURL.path) else {
throw CAStoreError.missingPrivateKeyOnDisk
}
return try Data(contentsOf: privateKeyURL)
}
}
115 changes: 115 additions & 0 deletions macos/Sources/ReverseAPIProxy/Export/HARExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation

public enum HARExporter {
private static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()

public static func export(_ flows: [CapturedFlow]) throws -> Data {
let entries = flows.map(entry(for:))
let har: [String: Any] = [
"log": [
"version": "1.2",
"creator": ["name": "rae", "version": "0.1"],
"entries": entries,
]
]
return try JSONSerialization.data(withJSONObject: har, options: [.prettyPrinted, .sortedKeys])
}

static func entry(for flow: CapturedFlow) -> [String: Any] {
let started = Self.dateFormatter.string(from: flow.startedAt)
let duration = ((flow.finishedAt ?? flow.startedAt).timeIntervalSince(flow.startedAt)) * 1000

let requestContentType = header(flow.requestHeaders, "content-type")
let responseContentType = header(flow.responseHeaders, "content-type")

var request: [String: Any] = [
"method": flow.method,
"url": flow.url,
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": flow.requestHeaders.map { ["name": $0.name, "value": $0.value] },
"queryString": queryString(from: flow.path),
"headersSize": -1,
"bodySize": flow.requestBody.count,
]
if !flow.requestBody.isEmpty {
var postData: [String: Any] = ["mimeType": requestContentType ?? ""]
if let text = String(data: flow.requestBody, encoding: .utf8) {
postData["text"] = text
} else {
postData["encoding"] = "base64"
postData["text"] = flow.requestBody.base64EncodedString()
}
request["postData"] = postData
}

var responseContent: [String: Any] = [
"size": flow.responseBody.count,
"mimeType": responseContentType ?? "",
]
if !flow.responseBody.isEmpty {
if let text = String(data: flow.responseBody, encoding: .utf8) {
responseContent["text"] = text
} else {
responseContent["encoding"] = "base64"
responseContent["text"] = flow.responseBody.base64EncodedString()
}
}

var record: [String: Any] = [
"startedDateTime": started,
"time": duration,
"request": request,
"response": [
"status": flow.responseStatus ?? 0,
"statusText": "",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": flow.responseHeaders.map { ["name": $0.name, "value": $0.value] },
"content": responseContent,
"redirectURL": "",
"headersSize": -1,
"bodySize": flow.responseBody.count,
],
"cache": [:],
"timings": [
"send": 0,
"wait": duration,
"receive": 0,
],
]
if let error = flow.error {
record["_error"] = error
}
return record
}

private static func header(_ headers: [HTTPHeader], _ name: String) -> String? {
let lower = name.lowercased()
return headers.first(where: { $0.name.lowercased() == lower })?.value
}

static func queryString(from path: String) -> [[String: String]] {
guard let queryIndex = path.firstIndex(of: "?") else { return [] }
let rawQuery = path[path.index(after: queryIndex)...]
let query = rawQuery.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first ?? ""
return query.split(separator: "&").compactMap { pair in
let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
guard let name = parts.first else { return nil }
let value = parts.count > 1 ? String(parts[1]) : ""
return [
"name": decodeFormComponent(String(name)),
"value": decodeFormComponent(value),
]
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

static func decodeFormComponent(_ value: String) -> String {
let withSpaces = value.replacingOccurrences(of: "+", with: " ")
return withSpaces.removingPercentEncoding ?? withSpaces
}
}
25 changes: 25 additions & 0 deletions macos/Sources/ReverseAPIProxy/Proxy/ProxyHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ final class ProxyHandler: ChannelInboundHandler, RemovableChannelHandler, @unche
let channel = channelContext.channel
let eventLoop = channelContext.eventLoop

if isWebSocketUpgrade(inflight.head) {
let flow = makeFlow(from: inflight)
var failed = flow
failed.responseStatus = Int(HTTPResponseStatus.badGateway.code)
failed.error = "WebSocket upgrades are not supported yet"
failed.finishedAt = Date()
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Task {
await proxyContext.bus.emit(.started(flow))
await proxyContext.bus.emit(.finished(failed))
}
respondError(channelContext: channelContext, status: .badGateway)
return
}

var headersForUpstream = inflight.head.headers
sanitizeRequestHeaders(&headersForUpstream)
let flow = makeFlow(from: inflight)
Expand Down Expand Up @@ -187,6 +201,17 @@ final class ProxyHandler: ChannelInboundHandler, RemovableChannelHandler, @unche
headers.replaceOrAdd(name: "Accept-Encoding", value: "identity")
}

static func isWebSocketUpgrade(_ head: HTTPRequestHead) -> Bool {
let tokens = head.headers["Upgrade"]
.flatMap { $0.split(separator: ",") }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
return tokens.contains("websocket")
}

private func isWebSocketUpgrade(_ head: HTTPRequestHead) -> Bool {
Self.isWebSocketUpgrade(head)
}

private func makeFlow(from inflight: InflightRequest) -> CapturedFlow {
var flow = CapturedFlow(
scheme: inflight.scheme,
Expand Down
Loading