Files
openclaw/apps/macos/Sources/OpenClaw/MacGatewayProfiles.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()
}
}
}