mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 22:31:40 +00:00
355 lines
14 KiB
Swift
355 lines
14 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import OpenClawKit
|
|
import Security
|
|
|
|
struct MacGatewayProfile: Codable, Equatable, Identifiable, Sendable {
|
|
let id: String
|
|
var name: String
|
|
var url: URL
|
|
}
|
|
|
|
enum MacGatewayProfileError: LocalizedError, Equatable {
|
|
case invalidURL
|
|
case insecureRemoteURL
|
|
case profileNotFound
|
|
case unsupportedRegistryVersion(Int)
|
|
case keychain(OSStatus)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL:
|
|
"Enter a ws:// or wss:// Gateway URL."
|
|
case .insecureRemoteURL:
|
|
"Public Gateway hosts require wss://. Use ws:// only on loopback, a trusted private network, or Tailnet."
|
|
case .profileNotFound:
|
|
"That Gateway profile no longer exists."
|
|
case let .unsupportedRegistryVersion(version):
|
|
"Gateway profiles were written by a newer OpenClaw version (schema \(version))."
|
|
case let .keychain(status):
|
|
"Could not save Gateway settings in Keychain (\(status))."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persistent gateway identities and credentials for independently routed windows.
|
|
/// Profiles are Keychain-backed so endpoint ownership and its secrets commit together.
|
|
actor MacGatewayProfileStore {
|
|
static let shared = MacGatewayProfileStore()
|
|
|
|
static let didChangeNotification = Notification.Name("openclaw.gateway-profiles.did-change")
|
|
|
|
struct StoredProfile: Codable, Equatable {
|
|
var profile: MacGatewayProfile
|
|
var credentials: Credentials
|
|
}
|
|
|
|
struct Registry: Codable, Equatable {
|
|
var version = 1
|
|
var legacyPrimaryMigrationVersion: Int?
|
|
var profiles: [StoredProfile] = []
|
|
}
|
|
|
|
struct Credentials: Codable, Equatable {
|
|
var token: String?
|
|
var password: String?
|
|
}
|
|
|
|
// Dev builds carry a different code signature; creating the release item
|
|
// would poison its Keychain ACL and make the shipped app demand the login
|
|
// keychain password on every read. DEBUG is a config heuristic, not a
|
|
// signing check: it covers swift build/Xcode dev runs, the observed
|
|
// poisoning path. Release-config ad-hoc builds stay out of scope; running
|
|
// those against saved Keychain items is already unsupported.
|
|
#if DEBUG
|
|
private static let service = "ai.openclaw.gateway-profiles.debug"
|
|
#else
|
|
private static let service = "ai.openclaw.gateway-profiles"
|
|
#endif
|
|
private static let registryAccount = "registry-v1"
|
|
private static let currentLegacyPrimaryMigrationVersion = 1
|
|
|
|
/// Registry reads are prompt-bearing: when this binary is missing from the
|
|
/// item's ACL, every SecItemCopyMatching raises a login-keychain dialog, and
|
|
/// catalog refreshes fire per control-channel state change. Cache the one
|
|
/// registry for the process lifetime; saves keep it coherent.
|
|
private var cachedRegistry: Registry?
|
|
|
|
static func migratingLegacyPrimaryConnection(
|
|
root: [String: Any],
|
|
registry: Registry) -> Registry
|
|
{
|
|
guard (registry.legacyPrimaryMigrationVersion ?? 0) < self.currentLegacyPrimaryMigrationVersion else {
|
|
return registry
|
|
}
|
|
var migrated = registry
|
|
migrated.legacyPrimaryMigrationVersion = self.currentLegacyPrimaryMigrationVersion
|
|
|
|
let mode = ConnectionModeResolver.resolve(root: root)
|
|
let resolution = GatewayRemoteConfig.resolveTransportResolution(root: root)
|
|
guard mode.mode == .remote,
|
|
mode.source == .configMode || mode.source == .configRemoteURL,
|
|
resolution.transport == .direct,
|
|
let directURL = resolution.directURL,
|
|
let profile = try? self.makeProfile(name: "", url: directURL),
|
|
!migrated.profiles.contains(where: { $0.profile.id == profile.id })
|
|
else {
|
|
return migrated
|
|
}
|
|
|
|
migrated.profiles.append(StoredProfile(
|
|
profile: profile,
|
|
credentials: Credentials(
|
|
token: GatewayRemoteConfig.resolveTokenString(root: root),
|
|
password: GatewayRemoteConfig.resolvePasswordString(root: root))))
|
|
return migrated
|
|
}
|
|
|
|
func upsert(
|
|
name: String,
|
|
url: URL,
|
|
token: String?,
|
|
password: String?) throws -> MacGatewayProfile
|
|
{
|
|
let profile = try Self.makeProfile(name: name, url: url)
|
|
var registry = try self.loadRegistry()
|
|
let id = profile.id
|
|
let savedCredentials = registry.profiles.first { $0.profile.id == id }?.credentials
|
|
let credentials = Self.resolvedCredentials(
|
|
saved: savedCredentials,
|
|
submittedToken: token,
|
|
submittedPassword: password)
|
|
registry.profiles.removeAll { $0.profile.id == id }
|
|
registry.profiles.append(StoredProfile(profile: profile, credentials: credentials))
|
|
// Metadata and secrets share one Keychain value, so the profile becomes
|
|
// reachable only when the complete record commits.
|
|
try self.saveRegistry(registry)
|
|
NotificationCenter.default.post(name: Self.didChangeNotification, object: nil)
|
|
return profile
|
|
}
|
|
|
|
func profiles() throws -> [MacGatewayProfile] {
|
|
try Self.sortedProfiles(self.loadRegistryMigratingLegacyPrimary().profiles.map(\.profile))
|
|
}
|
|
|
|
func catalogProfiles() throws -> [MacGatewayCatalogProfile] {
|
|
let stored = try self.loadRegistryMigratingLegacyPrimary().profiles
|
|
return Self.sortedProfiles(stored.map(\.profile)).compactMap { profile in
|
|
guard let item = stored.first(where: { $0.profile.id == profile.id }) else { return nil }
|
|
let token = item.credentials.token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return MacGatewayCatalogProfile(profile: profile, canPromote: token?.isEmpty == false)
|
|
}
|
|
}
|
|
|
|
func remove(profileID: String) throws {
|
|
var registry = try self.loadRegistry()
|
|
guard registry.profiles.contains(where: { $0.profile.id == profileID }) else {
|
|
throw MacGatewayProfileError.profileNotFound
|
|
}
|
|
registry.profiles.removeAll { $0.profile.id == profileID }
|
|
try self.saveRegistry(registry)
|
|
NotificationCenter.default.post(name: Self.didChangeNotification, object: nil)
|
|
}
|
|
|
|
func endpoint(profileID: String) throws -> GatewayConnection.EndpointSnapshot {
|
|
let registry = try self.loadRegistry()
|
|
guard let stored = registry.profiles.first(where: { $0.profile.id == profileID }) else {
|
|
throw MacGatewayProfileError.profileNotFound
|
|
}
|
|
let url = try Self.canonicalURL(stored.profile.url)
|
|
return GatewayConnection.EndpointSnapshot(
|
|
config: (
|
|
url: url,
|
|
token: stored.credentials.token,
|
|
password: stored.credentials.password),
|
|
tls: Self.tlsRoute(for: stored.profile),
|
|
routeAuthority: nil,
|
|
deviceAuthGatewayID: stored.profile.id)
|
|
}
|
|
|
|
private func loadRegistry() throws -> Registry {
|
|
if let cachedRegistry { return cachedRegistry }
|
|
let registry: Registry = if let data = try Self.load(account: Self.registryAccount) {
|
|
try Self.decodeRegistry(data)
|
|
} else {
|
|
Registry()
|
|
}
|
|
self.cachedRegistry = registry
|
|
return registry
|
|
}
|
|
|
|
private func loadRegistryMigratingLegacyPrimary() throws -> Registry {
|
|
let registry = try self.loadRegistry()
|
|
// Keep the receipt in the registry so removing the imported profile is durable.
|
|
// A failed Keychain commit leaves both changes unapplied and retries on the next read.
|
|
guard (registry.legacyPrimaryMigrationVersion ?? 0) < Self.currentLegacyPrimaryMigrationVersion else {
|
|
return registry
|
|
}
|
|
let migrated = Self.migratingLegacyPrimaryConnection(
|
|
root: OpenClawConfigFile.loadDict(),
|
|
registry: registry)
|
|
guard migrated != registry else { return registry }
|
|
try self.saveRegistry(migrated)
|
|
return migrated
|
|
}
|
|
|
|
private func saveRegistry(_ registry: Registry) throws {
|
|
try Self.save(JSONEncoder().encode(registry), account: Self.registryAccount)
|
|
self.cachedRegistry = registry
|
|
}
|
|
|
|
private static func decodeRegistry(_ data: Data) throws -> Registry {
|
|
let registry = try JSONDecoder().decode(Registry.self, from: data)
|
|
guard registry.version == 1 else {
|
|
throw MacGatewayProfileError.unsupportedRegistryVersion(registry.version)
|
|
}
|
|
return registry
|
|
}
|
|
|
|
static func validateRegistryData(_ data: Data) throws {
|
|
_ = try MacGatewayProfileStore.decodeRegistry(data)
|
|
}
|
|
|
|
static func sortedProfiles(_ profiles: [MacGatewayProfile]) -> [MacGatewayProfile] {
|
|
profiles.sorted { lhs, rhs in
|
|
let nameOrder = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
|
|
if nameOrder != .orderedSame {
|
|
return nameOrder == .orderedAscending
|
|
}
|
|
return lhs.url.absoluteString.localizedCaseInsensitiveCompare(rhs.url.absoluteString) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private static func makeProfile(name: String, url: URL) throws -> MacGatewayProfile {
|
|
let canonicalURL = try self.canonicalURL(url)
|
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return MacGatewayProfile(
|
|
id: self.profileID(url: canonicalURL),
|
|
name: trimmedName.isEmpty ? (canonicalURL.host ?? canonicalURL.absoluteString) : trimmedName,
|
|
url: canonicalURL)
|
|
}
|
|
|
|
static func canonicalURL(_ url: URL) throws -> URL {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let scheme = components.scheme?.lowercased(),
|
|
["ws", "wss"].contains(scheme),
|
|
let host = components.host?.lowercased(),
|
|
!host.isEmpty
|
|
else { throw MacGatewayProfileError.invalidURL }
|
|
if scheme == "ws", !GatewayRemoteConfig.allowsPlaintextGatewayHost(host) {
|
|
throw MacGatewayProfileError.insecureRemoteURL
|
|
}
|
|
components.scheme = scheme
|
|
components.host = host
|
|
if components.port == nil {
|
|
components.port = scheme == "wss" ? 443 : 18789
|
|
}
|
|
if components.percentEncodedPath.isEmpty {
|
|
components.percentEncodedPath = "/"
|
|
}
|
|
components.fragment = nil
|
|
guard let canonical = components.url else { throw MacGatewayProfileError.invalidURL }
|
|
return canonical
|
|
}
|
|
|
|
static func profileID(url: URL) -> String {
|
|
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
|
return "manual-" + digest.prefix(16).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
static func tlsRoute(for profile: MacGatewayProfile) -> GatewayTLSRoute? {
|
|
GatewayTLSRoute.resolve(
|
|
url: profile.url,
|
|
connectionMode: .remote,
|
|
configuredFingerprint: nil,
|
|
storeKey: "profile:\(profile.id)")
|
|
}
|
|
|
|
static func resolvedCredentials(
|
|
saved: Credentials?,
|
|
submittedToken: String?,
|
|
submittedPassword: String?) -> Credentials
|
|
{
|
|
let submitted = Credentials(
|
|
token: Self.normalizedSecret(submittedToken),
|
|
password: Self.normalizedSecret(submittedPassword))
|
|
// An empty New Gateway form means "reuse this saved route", not
|
|
// "erase its authentication". Supplying either field replaces both.
|
|
if submitted.token == nil, submitted.password == nil {
|
|
return saved ?? submitted
|
|
}
|
|
return submitted
|
|
}
|
|
|
|
private static func normalizedSecret(_ value: String?) -> String? {
|
|
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return value?.isEmpty == false ? value : nil
|
|
}
|
|
|
|
private static func load(account: String) throws -> Data? {
|
|
var query = self.baseQuery(account: account)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var result: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
if status == errSecItemNotFound { return nil }
|
|
guard status == errSecSuccess, let data = result as? Data else {
|
|
throw MacGatewayProfileError.keychain(status)
|
|
}
|
|
return data
|
|
}
|
|
|
|
private static func save(_ data: Data, account: String) throws {
|
|
let query = self.baseQuery(account: account)
|
|
let update = SecItemUpdate(
|
|
query as CFDictionary,
|
|
[kSecValueData as String: data] as CFDictionary)
|
|
if update == errSecSuccess { return }
|
|
guard update == errSecItemNotFound else { throw MacGatewayProfileError.keychain(update) }
|
|
var add = query
|
|
add[kSecValueData as String] = data
|
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
let status = SecItemAdd(add as CFDictionary, nil)
|
|
guard status == errSecSuccess else { throw MacGatewayProfileError.keychain(status) }
|
|
}
|
|
|
|
private static func baseQuery(account: String) -> [String: Any] {
|
|
[
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: self.service,
|
|
kSecAttrAccount as String: account,
|
|
kSecAttrSynchronizable as String: false,
|
|
]
|
|
}
|
|
}
|
|
|
|
actor MacGatewayConnectionFleet {
|
|
static let shared = MacGatewayConnectionFleet()
|
|
|
|
private var connections: [String: GatewayConnection] = [:]
|
|
|
|
func connection(profileID: String) -> GatewayConnection {
|
|
if let connection = self.connections[profileID] { return connection }
|
|
let connection = GatewayConnection(
|
|
endpointProvider: {
|
|
try await MacGatewayProfileStore.shared.endpoint(profileID: profileID)
|
|
},
|
|
supportsSharedEndpointRecovery: false)
|
|
self.connections[profileID] = connection
|
|
return connection
|
|
}
|
|
|
|
func remove(profileID: String) async {
|
|
guard let connection = self.connections.removeValue(forKey: profileID) else { return }
|
|
await connection.shutdown()
|
|
}
|
|
|
|
func shutdown() async {
|
|
let connections = self.connections.values
|
|
self.connections.removeAll()
|
|
for connection in connections {
|
|
await connection.shutdown()
|
|
}
|
|
}
|
|
}
|