From b1ba1d2ef581526b91939145433b01bc56dbd8c1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 17 Jul 2026 12:18:53 +0700 Subject: [PATCH] feat(connections): connect through a SOCKS5 proxy (#1882) --- CHANGELOG.md | 3 + .../Database/DatabaseManager+SOCKSProxy.swift | 36 ++ .../Core/Database/DatabaseManager+SSH.swift | 2 + .../DatabaseManager+SystemEvents.swift | 2 + .../Database/DatabaseManager+Tunnel.swift | 1 + .../Plugins/PluginManager+Registration.swift | 5 + .../Core/Plugins/PluginMetadataRegistry.swift | 2 + TablePro/Core/SOCKS/SOCKSProxyManager.swift | 407 ++++++++++++++++++ TablePro/Core/Storage/ConnectionStorage.swift | 34 ++ TablePro/Core/Storage/StoredConnection.swift | 20 + TablePro/Core/Sync/SyncRecordMapper.swift | 5 +- .../Connection/ConnectionTunnelKind.swift | 3 + .../DatabaseConnection+SOCKSProxy.swift | 16 + .../Connection/DatabaseConnection.swift | 9 +- .../Connection/SOCKSProxyConfiguration.swift | 29 ++ .../Connection/SOCKSProxyFormState.swift | 38 ++ .../Models/Connection/SOCKSProxyMode.swift | 46 ++ .../Connection/ConnectionSSHTunnelView.swift | 5 + .../Components/TunnelExclusivityBanner.swift | 29 ++ ...ionFormCoordinator+TunnelExclusivity.swift | 43 ++ .../ConnectionFormCoordinator.swift | 46 +- .../ConnectionForm/ConnectionFormPane.swift | 5 + .../ConnectionForm/ConnectionFormView.swift | 2 + .../Panes/CloudSQLProxyPaneView.swift | 25 +- .../Panes/CloudflareTunnelPaneView.swift | 17 +- .../Panes/SOCKSProxyPaneView.swift | 58 +++ .../ConnectionForm/Panes/SSHPaneView.swift | 3 +- .../CloudSQLProxyPaneViewModel.swift | 12 +- .../CloudflareTunnelPaneViewModel.swift | 12 +- .../ViewModels/SOCKSProxyPaneViewModel.swift | 51 +++ .../ViewModels/SSHPaneViewModel.swift | 11 +- .../ConnectionStoragePersistenceTests.swift | 37 ++ .../Models/ConnectionTunnelKindTests.swift | 55 ++- TableProTests/SOCKS/FakeSOCKS5Server.swift | 200 +++++++++ .../SOCKS/SOCKSProxyManagerTests.swift | 350 +++++++++++++++ .../SOCKS/SOCKSProxyModelTests.swift | 132 ++++++ .../SOCKS/SOCKSProxyPaneViewModelTests.swift | 57 +++ ...ConnectionFormTunnelExclusivityTests.swift | 78 ++++ docs/databases/overview.mdx | 55 +-- docs/databases/socks-proxy.mdx | 67 +++ docs/docs.json | 1 + 41 files changed, 1896 insertions(+), 113 deletions(-) create mode 100644 TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift create mode 100644 TablePro/Core/SOCKS/SOCKSProxyManager.swift create mode 100644 TablePro/Models/Connection/DatabaseConnection+SOCKSProxy.swift create mode 100644 TablePro/Models/Connection/SOCKSProxyConfiguration.swift create mode 100644 TablePro/Models/Connection/SOCKSProxyFormState.swift create mode 100644 TablePro/Models/Connection/SOCKSProxyMode.swift create mode 100644 TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift create mode 100644 TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift create mode 100644 TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift create mode 100644 TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift create mode 100644 TableProTests/SOCKS/FakeSOCKS5Server.swift create mode 100644 TableProTests/SOCKS/SOCKSProxyManagerTests.swift create mode 100644 TableProTests/SOCKS/SOCKSProxyModelTests.swift create mode 100644 TableProTests/SOCKS/SOCKSProxyPaneViewModelTests.swift create mode 100644 TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift create mode 100644 docs/databases/socks-proxy.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index d98a18625..aa68ed23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Connect through a SOCKS5 proxy. Set a host, port, and an optional username and password on the connection form's new SOCKS Proxy pane, alongside SSH Tunnel, Cloudflare Tunnel, and Cloud SQL Auth Proxy. The database hostname is resolved by the proxy, so names that only resolve behind it work. (#1882) - SQL Server connections can now use Windows Authentication (Kerberos) on macOS. Pick Windows Authentication in the connection form to sign in with the Kerberos ticket you already have from `kinit`, or enter a Kerberos principal and password to sign in with your domain credentials. Connect by hostname, not IP address. (#1879) - Teradata support through a downloadable driver written in native Swift. Connect over TD2 or TDNEGO logon, optionally with TLS, browse databases, tables, and columns, run SQL, edit rows, and create or alter tables. (#1867) @@ -18,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed duplicating a connection dropping its Cloudflare Tunnel and Cloud SQL Auth Proxy settings and stored secrets. +- Fixed the tunnel panes warning about only some of the other enabled connection methods. Each pane now lists every conflicting method with a button to turn it off. - Fixed Copy as, the context menu's Delete, and the Edit menu's copy and delete commands acting on only the active row instead of every row in a cell-range or column selection in the data grid. (#1898) - Fixed the Edit menu's Copy as JSON copying the wrong rows when the grid was sorted or filtered. (#1898) - Fixed tables picked in the sidebar showing up unchecked and getting silently skipped when exporting to SQL or MQL. The export sheet now checks them and applies the format's default per-table options, so Export works right away. diff --git a/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift b/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift new file mode 100644 index 000000000..c25cd73dc --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+SOCKSProxy.swift @@ -0,0 +1,36 @@ +// +// DatabaseManager+SOCKSProxy.swift +// TablePro +// + +import Foundation + +extension DatabaseManager { + func buildSOCKSProxyEffectiveConnection( + for connection: DatabaseConnection + ) async throws -> DatabaseConnection { + guard let config = connection.resolvedSOCKSProxyConfig else { return connection } + + let password = config.username.isEmpty + ? nil + : connectionStorage.loadSOCKSProxyPassword(for: connection.id) + + let tunnelPort = try await SOCKSProxyManager.shared.createTunnel( + connectionId: connection.id, + config: config, + password: password, + targetHost: connection.host, + targetPort: connection.port + ) + + return tunneledConnection(from: connection, localPort: tunnelPort) + } + + func handleSOCKSProxyTunnelDied(connectionId: UUID) async { + await recoverDeadTunnel( + connectionId: connectionId, + kind: "SOCKS Proxy", + disconnectedMessage: String(localized: "SOCKS proxy disconnected. Click to reconnect.") + ) + } +} diff --git a/TablePro/Core/Database/DatabaseManager+SSH.swift b/TablePro/Core/Database/DatabaseManager+SSH.swift index 15270efce..0e4334421 100644 --- a/TablePro/Core/Database/DatabaseManager+SSH.swift +++ b/TablePro/Core/Database/DatabaseManager+SSH.swift @@ -31,6 +31,8 @@ extension DatabaseManager { return try await buildCloudflareEffectiveConnection(for: connection) case .cloudSQLProxy: return try await buildCloudSQLProxyEffectiveConnection(for: connection) + case .socksProxy: + return try await buildSOCKSProxyEffectiveConnection(for: connection) case .ssh, .none: break } diff --git a/TablePro/Core/Database/DatabaseManager+SystemEvents.swift b/TablePro/Core/Database/DatabaseManager+SystemEvents.swift index 0277dc497..89befd2ce 100644 --- a/TablePro/Core/Database/DatabaseManager+SystemEvents.swift +++ b/TablePro/Core/Database/DatabaseManager+SystemEvents.swift @@ -50,6 +50,8 @@ extension DatabaseManager { await handleCloudflareTunnelDied(connectionId: connectionId) case .cloudSQLProxy: await handleCloudSQLProxyTunnelDied(connectionId: connectionId) + case .socksProxy: + await handleSOCKSProxyTunnelDied(connectionId: connectionId) } } } diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index de0a2eca8..67821eb64 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -76,6 +76,7 @@ extension DatabaseManager { case .ssh: return SSHTunnelManager.shared case .cloudflare: return CloudflareTunnelManager.shared case .cloudSQLProxy: return CloudSQLProxyManager.shared + case .socksProxy: return SOCKSProxyManager.shared case .none: return nil } } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 00d86f21e..d4dda687d 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -466,6 +466,11 @@ extension PluginManager { .capabilities.supportsCloudflareTunnel ?? true } + func supportsSOCKSProxy(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? + .capabilities.supportsSOCKSProxy ?? true + } + func supportsColumnReorder(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)? .supportsColumnReorder ?? false diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index c0ca27aa3..afafbc51a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -62,6 +62,8 @@ struct PluginMetadataSnapshot: Sendable { var supportsCloudflareTunnel: Bool = true var supportsClientKeyPassphrase: Bool = false + var supportsSOCKSProxy: Bool { supportsSSH } + static let defaults = CapabilityFlags( supportsSchemaSwitching: false, supportsImport: true, diff --git a/TablePro/Core/SOCKS/SOCKSProxyManager.swift b/TablePro/Core/SOCKS/SOCKSProxyManager.swift new file mode 100644 index 000000000..cf538a0e0 --- /dev/null +++ b/TablePro/Core/SOCKS/SOCKSProxyManager.swift @@ -0,0 +1,407 @@ +// +// SOCKSProxyManager.swift +// TablePro +// + +import Foundation +import Network +import os + +enum SOCKSProxyError: Error, LocalizedError, Equatable { + case invalidConfiguration + case listenerFailed(String) + case connectTimedOut(proxyHost: String, proxyPort: Int) + case connectFailed(proxyHost: String, proxyPort: Int, underlying: String) + + var errorDescription: String? { + switch self { + case .invalidConfiguration: + return String(localized: "The SOCKS proxy configuration is incomplete. Enter a proxy host and port.") + case .listenerFailed(let reason): + return String(format: String(localized: "Could not open a local port for the SOCKS proxy: %@"), reason) + case .connectTimedOut(let proxyHost, let proxyPort): + return String( + format: String(localized: "Timed out connecting through the SOCKS proxy at %@:%@. Check that the proxy is reachable and the credentials are correct."), + proxyHost, String(proxyPort) + ) + case .connectFailed(let proxyHost, let proxyPort, let underlying): + return String( + format: String(localized: "Could not connect through the SOCKS proxy at %@:%@. %@"), + proxyHost, String(proxyPort), underlying + ) + } + } +} + +actor SOCKSProxyManager: TunnelManaging { + static let shared = SOCKSProxyManager() + private static let logger = Logger(subsystem: "com.TablePro", category: "SOCKSProxyManager") + private static let networkQueue = DispatchQueue(label: "com.TablePro.SOCKSProxyManager.network") + + private struct RelayPair { + let inbound: NWConnection + let outbound: NWConnection + } + + private struct TunnelState { + let listener: NWListener + let localPort: Int + var relays: [UUID: RelayPair] = [:] + } + + private var tunnels: [UUID: TunnelState] = [:] + private var appNapActivity: NSObjectProtocol? + private let connectTimeout: TimeInterval + + init(connectTimeout: TimeInterval = 15) { + self.connectTimeout = connectTimeout + } + + func createTunnel( + connectionId: UUID, + config: SOCKSProxyConfiguration, + password: String?, + targetHost: String, + targetPort: Int + ) async throws -> Int { + guard config.isValid, Self.nwPort(config.port) != nil, Self.nwPort(targetPort) != nil else { + throw SOCKSProxyError.invalidConfiguration + } + + if tunnels[connectionId] != nil { + try await closeTunnel(connectionId: connectionId) + } + + let privacyContext = Self.makePrivacyContext(connectionId: connectionId, config: config, password: password) + try await probeProxyPath(config: config, privacyContext: privacyContext, targetHost: targetHost, targetPort: targetPort) + + let listener = try makeListener() + listener.newConnectionHandler = { [weak self] inbound in + guard let self else { + inbound.cancel() + return + } + Task { + await self.acceptClient( + inbound, + connectionId: connectionId, + config: config, + privacyContext: privacyContext, + targetHost: targetHost, + targetPort: targetPort + ) + } + } + listener.stateUpdateHandler = { [weak self] state in + guard case .failed(let error) = state else { return } + Task { await self?.handleListenerDeath(connectionId: connectionId, listener: listener, error: error) } + } + + let localPort = try await Self.startListener(listener) + tunnels[connectionId] = TunnelState(listener: listener, localPort: localPort) + updateAppNapState() + Self.logger.info("SOCKS proxy tunnel ready for \(connectionId.uuidString, privacy: .public) on 127.0.0.1:\(localPort)") + return localPort + } + + func closeTunnel(connectionId: UUID) async throws { + guard let state = tunnels.removeValue(forKey: connectionId) else { return } + updateAppNapState() + state.listener.stateUpdateHandler = nil + state.listener.cancel() + for relay in state.relays.values { + relay.inbound.cancel() + relay.outbound.cancel() + } + } + + func closeAllTunnels() async { + let connectionIds = Array(tunnels.keys) + for connectionId in connectionIds { + try? await closeTunnel(connectionId: connectionId) + } + } + + func hasTunnel(connectionId: UUID) -> Bool { + tunnels[connectionId] != nil + } + + func getLocalPort(connectionId: UUID) -> Int? { + tunnels[connectionId]?.localPort + } + + private func makeListener() throws -> NWListener { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) + parameters.preferNoProxies = true + do { + return try NWListener(using: parameters) + } catch { + throw SOCKSProxyError.listenerFailed(error.localizedDescription) + } + } + + private func probeProxyPath( + config: SOCKSProxyConfiguration, + privacyContext: NWParameters.PrivacyContext, + targetHost: String, + targetPort: Int + ) async throws { + let probe = Self.makeProxiedConnection(privacyContext: privacyContext, targetHost: targetHost, targetPort: targetPort) + defer { probe.cancel() } + try await Self.waitUntilReady(probe, timeout: connectTimeout, proxyHost: config.host, proxyPort: config.port) + } + + private func acceptClient( + _ inbound: NWConnection, + connectionId: UUID, + config: SOCKSProxyConfiguration, + privacyContext: NWParameters.PrivacyContext, + targetHost: String, + targetPort: Int + ) { + guard tunnels[connectionId] != nil else { + inbound.cancel() + return + } + + let relayId = UUID() + let outbound = Self.makeProxiedConnection(privacyContext: privacyContext, targetHost: targetHost, targetPort: targetPort) + tunnels[connectionId]?.relays[relayId] = RelayPair(inbound: inbound, outbound: outbound) + + inbound.stateUpdateHandler = { [weak self] state in + switch state { + case .failed, .cancelled: + Task { await self?.removeRelay(connectionId: connectionId, relayId: relayId) } + default: + break + } + } + inbound.start(queue: Self.networkQueue) + + Task { [connectTimeout] in + do { + try await Self.waitUntilReady(outbound, timeout: connectTimeout, proxyHost: config.host, proxyPort: config.port) + outbound.stateUpdateHandler = { [weak self] state in + switch state { + case .failed, .cancelled: + Task { await self?.removeRelay(connectionId: connectionId, relayId: relayId) } + default: + break + } + } + let finishedDirections = OSAllocatedUnfairLock(initialState: 0) + let onDirectionFinished: @Sendable () -> Void = { [weak self] in + let bothFinished = finishedDirections.withLock { count -> Bool in + count += 1 + return count == 2 + } + guard bothFinished else { return } + Task { await self?.removeRelay(connectionId: connectionId, relayId: relayId) } + } + Self.pump(inbound, into: outbound, onFinished: onDirectionFinished) + Self.pump(outbound, into: inbound, onFinished: onDirectionFinished) + } catch { + Self.logger.warning("SOCKS relay setup failed for \(connectionId.uuidString, privacy: .public): \(error.localizedDescription)") + await self.removeRelay(connectionId: connectionId, relayId: relayId) + } + } + } + + private func removeRelay(connectionId: UUID, relayId: UUID) { + guard let relay = tunnels[connectionId]?.relays.removeValue(forKey: relayId) else { return } + relay.inbound.cancel() + relay.outbound.cancel() + } + + private func handleListenerDeath(connectionId: UUID, listener: NWListener, error: NWError) async { + guard let state = tunnels[connectionId], state.listener === listener else { return } + tunnels.removeValue(forKey: connectionId) + updateAppNapState() + for relay in state.relays.values { + relay.inbound.cancel() + relay.outbound.cancel() + } + Self.logger.warning("SOCKS proxy listener died for \(connectionId.uuidString, privacy: .public): \(error.localizedDescription)") + await DatabaseManager.shared.handleSOCKSProxyTunnelDied(connectionId: connectionId) + } + + private func updateAppNapState() { + if !tunnels.isEmpty, appNapActivity == nil { + appNapActivity = ProcessInfo.processInfo.beginActivity( + options: .userInitiatedAllowingIdleSystemSleep, + reason: "SOCKS proxy tunnel requires timely execution" + ) + } else if tunnels.isEmpty, let activity = appNapActivity { + ProcessInfo.processInfo.endActivity(activity) + appNapActivity = nil + } + } + + private static func nwPort(_ port: Int) -> NWEndpoint.Port? { + UInt16(exactly: port).flatMap { $0 > 0 ? NWEndpoint.Port(rawValue: $0) : nil } + } + + private static func makePrivacyContext( + connectionId: UUID, + config: SOCKSProxyConfiguration, + password: String? + ) -> NWParameters.PrivacyContext { + let proxyEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host(config.host), + port: nwPort(config.port) ?? 1_080 + ) + var proxy = ProxyConfiguration(socksv5Proxy: proxyEndpoint) + if !config.username.isEmpty, let password, !password.isEmpty { + proxy.applyCredential(username: config.username, password: password) + } + let context = NWParameters.PrivacyContext(description: "TablePro-SOCKS-\(connectionId.uuidString)") + context.proxyConfigurations = [proxy] + return context + } + + private static func makeProxiedConnection( + privacyContext: NWParameters.PrivacyContext, + targetHost: String, + targetPort: Int + ) -> NWConnection { + let parameters = NWParameters.tcp + parameters.setPrivacyContext(privacyContext) + return NWConnection( + host: NWEndpoint.Host(targetHost), + port: nwPort(targetPort) ?? 1_080, + using: parameters + ) + } + + private static func startListener(_ listener: NWListener) async throws -> Int { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let existingHandler = listener.stateUpdateHandler + let finish: (Result) -> Void = { result in + let shouldResume = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard shouldResume else { return } + listener.stateUpdateHandler = existingHandler + continuation.resume(with: result) + } + listener.stateUpdateHandler = { state in + switch state { + case .ready: + if let port = listener.port { + finish(.success(Int(port.rawValue))) + } else { + listener.cancel() + finish(.failure(SOCKSProxyError.listenerFailed("no port assigned"))) + } + case .failed(let error): + listener.cancel() + finish(.failure(SOCKSProxyError.listenerFailed(error.localizedDescription))) + case .cancelled: + finish(.failure(CancellationError())) + default: + break + } + } + listener.start(queue: networkQueue) + } + } onCancel: { + listener.cancel() + } + } + + private static func waitUntilReady( + _ connection: NWConnection, + timeout: TimeInterval, + proxyHost: String, + proxyPort: Int + ) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let finish: (Result) -> Void = { result in + let shouldResume = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard shouldResume else { return } + continuation.resume(with: result) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.stateUpdateHandler = nil + finish(.success(())) + case .failed(let error): + connection.cancel() + finish(.failure(SOCKSProxyError.connectFailed( + proxyHost: proxyHost, + proxyPort: proxyPort, + underlying: error.localizedDescription + ))) + case .cancelled: + finish(.failure(CancellationError())) + default: + break + } + } + networkQueue.asyncAfter(deadline: .now() + timeout) { + let timedOut = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard timedOut else { return } + connection.cancel() + continuation.resume(throwing: SOCKSProxyError.connectTimedOut(proxyHost: proxyHost, proxyPort: proxyPort)) + } + connection.start(queue: networkQueue) + } + } onCancel: { + connection.cancel() + } + } + + private static func pump( + _ source: NWConnection, + into destination: NWConnection, + onFinished: @escaping @Sendable () -> Void + ) { + source.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { data, _, isComplete, error in + if let data, !data.isEmpty { + destination.send(content: data, completion: .contentProcessed { sendError in + guard sendError == nil else { + source.cancel() + destination.cancel() + onFinished() + return + } + if isComplete { + destination.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .idempotent) + onFinished() + return + } + pump(source, into: destination, onFinished: onFinished) + }) + return + } + if isComplete { + destination.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .idempotent) + onFinished() + return + } + if error != nil { + source.cancel() + destination.cancel() + onFinished() + return + } + pump(source, into: destination, onFinished: onFinished) + } + } +} diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 93d8455dd..bf5e947b8 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -259,6 +259,7 @@ final class ConnectionStorage { deleteCloudflareTokenId(for: connection.id) deleteCloudflareTokenSecret(for: connection.id) deleteCloudSQLProxyServiceAccountKey(for: connection.id) + deleteSOCKSProxyPassword(for: connection.id) let secureFieldIds = Self.secureFieldIds(for: connection.type) deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds) @@ -297,6 +298,7 @@ final class ConnectionStorage { deleteCloudflareTokenId(for: conn.id) deleteCloudflareTokenSecret(for: conn.id) deleteCloudSQLProxyServiceAccountKey(for: conn.id) + deleteSOCKSProxyPassword(for: conn.id) let fields = Self.secureFieldIds(for: conn.type) deleteAllPluginSecureFields(for: conn.id, fieldIds: fields) let appSettings = appSettingsProvider() @@ -334,6 +336,9 @@ final class ConnectionStorage { groupId: connection.groupId, sshProfileId: connection.sshProfileId, sshTunnelMode: connection.sshTunnelMode, + cloudflareTunnelMode: connection.cloudflareTunnelMode, + cloudSQLProxyMode: connection.cloudSQLProxyMode, + socksProxyMode: connection.socksProxyMode, safeModeLevel: connection.safeModeLevel, aiPolicy: connection.aiPolicy, aiRules: connection.aiRules, @@ -372,6 +377,18 @@ final class ConnectionStorage { if let totpSecret = loadTOTPSecret(for: connection.id) { saveTOTPSecret(totpSecret, for: newId) } + if let cloudflareTokenId = loadCloudflareTokenId(for: connection.id) { + saveCloudflareTokenId(cloudflareTokenId, for: newId) + } + if let cloudflareTokenSecret = loadCloudflareTokenSecret(for: connection.id) { + saveCloudflareTokenSecret(cloudflareTokenSecret, for: newId) + } + if let serviceAccountKey = loadCloudSQLProxyServiceAccountKey(for: connection.id) { + saveCloudSQLProxyServiceAccountKey(serviceAccountKey, for: newId) + } + if let socksProxyPassword = loadSOCKSProxyPassword(for: connection.id) { + saveSOCKSProxyPassword(socksProxyPassword, for: newId) + } let secureFieldIds = Self.secureFieldIds(for: connection.type) for fieldId in secureFieldIds { @@ -540,6 +557,23 @@ final class ConnectionStorage { keychain.delete(forKey: storageKey) } + // MARK: - SOCKS Proxy Password Storage + + func saveSOCKSProxyPassword(_ password: String, for connectionId: UUID) { + let key = "com.TablePro.socksproxypassword.\(connectionId.uuidString)" + keychain.writeString(password, forKey: key) + } + + func loadSOCKSProxyPassword(for connectionId: UUID) -> String? { + let key = "com.TablePro.socksproxypassword.\(connectionId.uuidString)" + return resolveString(.init(label: "SOCKS proxy password", connectionId: connectionId), forKey: key) + } + + func deleteSOCKSProxyPassword(for connectionId: UUID) { + let key = "com.TablePro.socksproxypassword.\(connectionId.uuidString)" + keychain.delete(forKey: key) + } + private struct SecretContext { let label: String let connectionId: UUID diff --git a/TablePro/Core/Storage/StoredConnection.swift b/TablePro/Core/Storage/StoredConnection.swift index 72fe2c54f..b656897e2 100644 --- a/TablePro/Core/Storage/StoredConnection.swift +++ b/TablePro/Core/Storage/StoredConnection.swift @@ -81,6 +81,9 @@ struct StoredConnection: Codable { // Cloud SQL Auth Proxy mode (JSON blob) let cloudSQLProxyModeJson: Data? + // SOCKS proxy mode (JSON blob) + let socksProxyModeJson: Data? + // Plugin-driven additional fields let additionalFields: [String: String]? @@ -163,6 +166,11 @@ struct StoredConnection: Codable { ? (try? JSONEncoder().encode(connection.cloudSQLProxyMode)) : nil + // SOCKS proxy mode (only persisted when enabled) + self.socksProxyModeJson = connection.isSOCKSProxyEnabled + ? (try? JSONEncoder().encode(connection.socksProxyMode)) + : nil + self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields // Password source (not synced to iCloud; see SyncRecordMapper) @@ -187,6 +195,7 @@ struct StoredConnection: Codable { case sshTunnelModeJson case cloudflareTunnelModeJson case cloudSQLProxyModeJson + case socksProxyModeJson case additionalFields case localOnly case isSample @@ -234,6 +243,7 @@ struct StoredConnection: Codable { try container.encodeIfPresent(sshTunnelModeJson, forKey: .sshTunnelModeJson) try container.encodeIfPresent(cloudflareTunnelModeJson, forKey: .cloudflareTunnelModeJson) try container.encodeIfPresent(cloudSQLProxyModeJson, forKey: .cloudSQLProxyModeJson) + try container.encodeIfPresent(socksProxyModeJson, forKey: .socksProxyModeJson) try container.encodeIfPresent(additionalFields, forKey: .additionalFields) try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) @@ -309,6 +319,7 @@ struct StoredConnection: Codable { sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson) cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson) cloudSQLProxyModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudSQLProxyModeJson) + socksProxyModeJson = try container.decodeIfPresent(Data.self, forKey: .socksProxyModeJson) additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false @@ -364,6 +375,14 @@ struct StoredConnection: Codable { resolvedCloudSQLProxyMode = .disabled } + let resolvedSOCKSProxyMode: SOCKSProxyMode + if let json = socksProxyModeJson, + let decoded = try? JSONDecoder().decode(SOCKSProxyMode.self, from: json) { + resolvedSOCKSProxyMode = decoded + } else { + resolvedSOCKSProxyMode = .disabled + } + var resolvedSSLCaPath = sslCaCertificatePath if type == "Cassandra", resolvedSSLCaPath.isEmpty, let legacy = additionalFields?["sslCaCertPath"], !legacy.isEmpty { @@ -424,6 +443,7 @@ struct StoredConnection: Codable { sshTunnelMode: resolvedTunnelMode, cloudflareTunnelMode: resolvedCloudflareMode, cloudSQLProxyMode: resolvedCloudSQLProxyMode, + socksProxyMode: resolvedSOCKSProxyMode, safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent, aiPolicy: parsedAIPolicy, aiRules: aiRules, diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 0afdf01da..0e969be36 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -116,8 +116,9 @@ struct SyncRecordMapper { // Note: sshTunnelMode is intentionally NOT synced — it is re-derived // on decode from sshConfig + sshProfileId. If adding sshTunnelMode to // the sync schema in the future, apply path contraction to its snapshot. - // cloudflareTunnelMode and cloudSQLProxyMode are also NOT synced: they are - // device-local runtime config and their secrets live in the Keychain. + // cloudflareTunnelMode, cloudSQLProxyMode, and socksProxyMode are also NOT + // synced: they are device-local runtime config and their secrets live in + // the Keychain. // passwordSource is also NOT synced: its file path, env var, or command // is device-local and may not exist or resolve on another Mac. do { diff --git a/TablePro/Models/Connection/ConnectionTunnelKind.swift b/TablePro/Models/Connection/ConnectionTunnelKind.swift index 275fd130c..5c45c30ba 100644 --- a/TablePro/Models/Connection/ConnectionTunnelKind.swift +++ b/TablePro/Models/Connection/ConnectionTunnelKind.swift @@ -9,12 +9,14 @@ enum ConnectionTunnelKind: String, CaseIterable, Sendable { case ssh case cloudflare case cloudSQLProxy + case socksProxy var displayName: String { switch self { case .ssh: return String(localized: "SSH Tunnel") case .cloudflare: return String(localized: "Cloudflare Tunnel") case .cloudSQLProxy: return String(localized: "Cloud SQL Auth Proxy") + case .socksProxy: return String(localized: "SOCKS Proxy") } } } @@ -25,6 +27,7 @@ extension DatabaseConnection { if resolvedSSHConfig.enabled { kinds.append(.ssh) } if isCloudflareEnabled { kinds.append(.cloudflare) } if isCloudSQLProxyEnabled { kinds.append(.cloudSQLProxy) } + if isSOCKSProxyEnabled { kinds.append(.socksProxy) } return kinds } diff --git a/TablePro/Models/Connection/DatabaseConnection+SOCKSProxy.swift b/TablePro/Models/Connection/DatabaseConnection+SOCKSProxy.swift new file mode 100644 index 000000000..bf489c037 --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnection+SOCKSProxy.swift @@ -0,0 +1,16 @@ +// +// DatabaseConnection+SOCKSProxy.swift +// TablePro +// + +extension DatabaseConnection { + var isSOCKSProxyEnabled: Bool { + if case .inline = socksProxyMode { return true } + return false + } + + var resolvedSOCKSProxyConfig: SOCKSProxyConfiguration? { + if case .inline(let config) = socksProxyMode { return config } + return nil + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 0823e5882..425efb4c7 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -349,6 +349,7 @@ struct DatabaseConnection: Identifiable, Hashable { var sshTunnelMode: SSHTunnelMode var cloudflareTunnelMode: CloudflareTunnelMode = .disabled var cloudSQLProxyMode: CloudSQLProxyMode = .disabled + var socksProxyMode: SOCKSProxyMode = .disabled var safeModeLevel: SafeModeLevel var aiPolicy: AIConnectionPolicy? var aiRules: String? @@ -453,6 +454,7 @@ struct DatabaseConnection: Identifiable, Hashable { sshTunnelMode: SSHTunnelMode = .disabled, cloudflareTunnelMode: CloudflareTunnelMode = .disabled, cloudSQLProxyMode: CloudSQLProxyMode = .disabled, + socksProxyMode: SOCKSProxyMode = .disabled, safeModeLevel: SafeModeLevel = .silent, aiPolicy: AIConnectionPolicy? = nil, aiRules: String? = nil, @@ -506,6 +508,7 @@ struct DatabaseConnection: Identifiable, Hashable { } self.cloudflareTunnelMode = cloudflareTunnelMode self.cloudSQLProxyMode = cloudSQLProxyMode + self.socksProxyMode = socksProxyMode self.aiPolicy = aiPolicy self.aiRules = aiRules self.aiAlwaysAllowedTools = aiAlwaysAllowedTools @@ -563,7 +566,7 @@ extension DatabaseConnection: Codable { private enum CodingKeys: String, CodingKey { case id, name, host, port, database, username, type case sshConfig, sslConfig, color, tagId, tagIds, groupId, sshProfileId - case sshTunnelMode, cloudflareTunnelMode, cloudSQLProxyMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields + case sshTunnelMode, cloudflareTunnelMode, cloudSQLProxyMode, socksProxyMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite case passwordSource } @@ -603,6 +606,7 @@ extension DatabaseConnection: Codable { passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled cloudSQLProxyMode = try container.decodeIfPresent(CloudSQLProxyMode.self, forKey: .cloudSQLProxyMode) ?? .disabled + socksProxyMode = try container.decodeIfPresent(SOCKSProxyMode.self, forKey: .socksProxyMode) ?? .disabled // Migrate from legacy fields if sshTunnelMode is not present if let tunnelMode = try container.decodeIfPresent(SSHTunnelMode.self, forKey: .sshTunnelMode) { @@ -645,6 +649,9 @@ extension DatabaseConnection: Codable { if case .inline = cloudSQLProxyMode { try container.encode(cloudSQLProxyMode, forKey: .cloudSQLProxyMode) } + if case .inline = socksProxyMode { + try container.encode(socksProxyMode, forKey: .socksProxyMode) + } try container.encode(safeModeLevel, forKey: .safeModeLevel) try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy) try container.encodeIfPresent(aiRules, forKey: .aiRules) diff --git a/TablePro/Models/Connection/SOCKSProxyConfiguration.swift b/TablePro/Models/Connection/SOCKSProxyConfiguration.swift new file mode 100644 index 000000000..29953e245 --- /dev/null +++ b/TablePro/Models/Connection/SOCKSProxyConfiguration.swift @@ -0,0 +1,29 @@ +// +// SOCKSProxyConfiguration.swift +// TablePro +// + +import Foundation + +struct SOCKSProxyConfiguration: Codable, Hashable, Sendable { + var host: String = "" + var port: Int = 1_080 + var username: String = "" + + var isValid: Bool { + !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (1...65_535).contains(port) + } +} + +extension SOCKSProxyConfiguration { + private enum CodingKeys: String, CodingKey { + case host, port, username + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" + port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 1_080 + username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" + } +} diff --git a/TablePro/Models/Connection/SOCKSProxyFormState.swift b/TablePro/Models/Connection/SOCKSProxyFormState.swift new file mode 100644 index 000000000..d2c592568 --- /dev/null +++ b/TablePro/Models/Connection/SOCKSProxyFormState.swift @@ -0,0 +1,38 @@ +// +// SOCKSProxyFormState.swift +// TablePro +// + +import Foundation + +struct SOCKSProxyFormState { + var enabled: Bool = false + var host: String = "" + var port: String = "1080" + var username: String = "" + var password: String = "" + + func buildConfig() -> SOCKSProxyConfiguration { + SOCKSProxyConfiguration( + host: host.trimmingCharacters(in: .whitespacesAndNewlines), + port: Int(port.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 1_080, + username: username.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + func buildTunnelMode() -> SOCKSProxyMode { + enabled ? .inline(buildConfig()) : .disabled + } + + mutating func load(from connection: DatabaseConnection) { + switch connection.socksProxyMode { + case .disabled: + enabled = false + case .inline(let config): + enabled = true + host = config.host + port = String(config.port) + username = config.username + } + } +} diff --git a/TablePro/Models/Connection/SOCKSProxyMode.swift b/TablePro/Models/Connection/SOCKSProxyMode.swift new file mode 100644 index 000000000..70662ac92 --- /dev/null +++ b/TablePro/Models/Connection/SOCKSProxyMode.swift @@ -0,0 +1,46 @@ +// +// SOCKSProxyMode.swift +// TablePro +// + +import Foundation + +enum SOCKSProxyMode: Hashable, Sendable { + case disabled + case inline(SOCKSProxyConfiguration) +} + +extension SOCKSProxyMode: Codable { + private enum CodingKeys: String, CodingKey { + case mode + case config + } + + private enum Mode: String, Codable { + case disabled + case inline + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let mode = try container.decode(Mode.self, forKey: .mode) + switch mode { + case .disabled: + self = .disabled + case .inline: + let config = try container.decode(SOCKSProxyConfiguration.self, forKey: .config) + self = .inline(config) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .disabled: + try container.encode(Mode.disabled, forKey: .mode) + case .inline(let config): + try container.encode(Mode.inline, forKey: .mode) + try container.encode(config, forKey: .config) + } + } +} diff --git a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift index 7ea4ef8ad..7fd66791b 100644 --- a/TablePro/Views/Connection/ConnectionSSHTunnelView.swift +++ b/TablePro/Views/Connection/ConnectionSSHTunnelView.swift @@ -11,6 +11,7 @@ struct ConnectionSSHTunnelView: View { @Binding var sshState: SSHTunnelFormState let databaseType: DatabaseType + var coordinator: ConnectionFormCoordinator? var body: some View { Form { @@ -24,6 +25,10 @@ struct ConnectionSSHTunnelView: View { } if sshState.enabled { + if let coordinator, !coordinator.otherEnabledTunnels(excluding: .ssh).isEmpty { + TunnelExclusivityBanner(coordinator: coordinator, currentKind: .ssh) + } + sshProfileSection if sshState.selectedProfile == nil, sshState.profileId != nil { diff --git a/TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift b/TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift new file mode 100644 index 000000000..ad73f8952 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Components/TunnelExclusivityBanner.swift @@ -0,0 +1,29 @@ +// +// TunnelExclusivityBanner.swift +// TablePro +// + +import SwiftUI + +struct TunnelExclusivityBanner: View { + let coordinator: ConnectionFormCoordinator + let currentKind: ConnectionTunnelKind + + var body: some View { + Section { + Label( + String( + format: String(localized: "A connection can use one connection method at a time. Disable the other methods to use %@."), + currentKind.displayName + ), + systemImage: "exclamationmark.triangle.fill" + ) + .foregroundStyle(.orange) + ForEach(coordinator.otherEnabledTunnels(excluding: currentKind)) { other in + Button(String(format: String(localized: "Disable %@"), other.kind.displayName)) { + other.disable() + } + } + } + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift new file mode 100644 index 000000000..278b71859 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator+TunnelExclusivity.swift @@ -0,0 +1,43 @@ +// +// ConnectionFormCoordinator+TunnelExclusivity.swift +// TablePro +// + +import Foundation + +@MainActor +extension ConnectionFormCoordinator { + struct EnabledTunnel: Identifiable { + let kind: ConnectionTunnelKind + let disable: () -> Void + + var id: String { kind.rawValue } + } + + var enabledTunnels: [EnabledTunnel] { + var tunnels: [EnabledTunnel] = [] + if ssh.state.enabled { + tunnels.append(EnabledTunnel(kind: .ssh) { [weak self] in self?.ssh.state.disable() }) + } + if cloudflareTunnel.state.enabled { + tunnels.append(EnabledTunnel(kind: .cloudflare) { [weak self] in + self?.cloudflareTunnel.state.enabled = false + }) + } + if cloudSQLProxy.state.enabled { + tunnels.append(EnabledTunnel(kind: .cloudSQLProxy) { [weak self] in + self?.cloudSQLProxy.state.enabled = false + }) + } + if socksProxy.state.enabled { + tunnels.append(EnabledTunnel(kind: .socksProxy) { [weak self] in + self?.socksProxy.state.enabled = false + }) + } + return tunnels + } + + func otherEnabledTunnels(excluding kind: ConnectionTunnelKind) -> [EnabledTunnel] { + enabledTunnels.filter { $0.kind != kind } + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index d2acdf715..27df8f2be 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -31,6 +31,7 @@ final class ConnectionFormCoordinator { var ssh: SSHPaneViewModel var cloudflareTunnel: CloudflareTunnelPaneViewModel var cloudSQLProxy: CloudSQLProxyPaneViewModel + var socksProxy: SOCKSProxyPaneViewModel var ssl: SSLPaneViewModel var customization: CustomizationPaneViewModel var advanced: AdvancedPaneViewModel @@ -73,6 +74,9 @@ final class ConnectionFormCoordinator { if network.type.supportsCloudSQLProxy { panes.append(.cloudSQLProxy) } + if services.pluginManager.supportsSOCKSProxy(for: network.type) { + panes.append(.socksProxy) + } if services.pluginManager.supportsSSL(for: network.type) { panes.append(.ssl) } @@ -88,6 +92,7 @@ final class ConnectionFormCoordinator { && ssh.validationIssues.isEmpty && cloudflareTunnel.validationIssues.isEmpty && cloudSQLProxy.validationIssues.isEmpty + && socksProxy.validationIssues.isEmpty && ssl.validationIssues.isEmpty && customization.validationIssues.isEmpty && advanced.validationIssues.isEmpty @@ -111,6 +116,7 @@ final class ConnectionFormCoordinator { self.ssh = SSHPaneViewModel() self.cloudflareTunnel = CloudflareTunnelPaneViewModel() self.cloudSQLProxy = CloudSQLProxyPaneViewModel() + self.socksProxy = SOCKSProxyPaneViewModel() self.ssl = SSLPaneViewModel() self.customization = CustomizationPaneViewModel() self.advanced = AdvancedPaneViewModel() @@ -122,6 +128,7 @@ final class ConnectionFormCoordinator { ssh.coordinator = ref cloudflareTunnel.coordinator = ref cloudSQLProxy.coordinator = ref + socksProxy.coordinator = ref ssl.coordinator = ref customization.coordinator = ref advanced.coordinator = ref @@ -168,6 +175,7 @@ final class ConnectionFormCoordinator { ssh.load(from: existing, storage: storage) cloudflareTunnel.load(from: existing, storage: storage) cloudSQLProxy.load(from: existing, storage: storage) + socksProxy.load(from: existing, storage: storage) ssl.load(from: existing) customization.load(from: existing) advanced.load(from: existing) @@ -269,6 +277,7 @@ final class ConnectionFormCoordinator { let sshTunnelMode = ssh.state.buildTunnelMode() let cloudflareTunnelMode = cloudflareTunnel.state.buildTunnelMode() let cloudSQLProxyMode = cloudSQLProxy.state.buildTunnelMode() + let socksProxyMode = socksProxy.state.buildTunnelMode() let connectionToSave = DatabaseConnection( id: finalId, name: network.name, @@ -286,6 +295,7 @@ final class ConnectionFormCoordinator { sshTunnelMode: sshTunnelMode, cloudflareTunnelMode: cloudflareTunnelMode, cloudSQLProxyMode: cloudSQLProxyMode, + socksProxyMode: socksProxyMode, safeModeLevel: customization.safeModeLevel, aiPolicy: advanced.aiPolicy, aiRules: aiRules.trimmedRules, @@ -332,6 +342,7 @@ final class ConnectionFormCoordinator { cloudflareTunnel.save(to: connectionToSave.id, storage: storage) cloudSQLProxy.save(to: connectionToSave.id, storage: storage) + socksProxy.save(to: connectionToSave.id, storage: storage) var savedConnections = storage.loadConnections() if isNew { @@ -452,6 +463,7 @@ final class ConnectionFormCoordinator { let testTunnelMode = ssh.state.buildTunnelMode() let testCloudflareMode = cloudflareTunnel.state.buildTunnelMode() let testCloudSQLProxyMode = cloudSQLProxy.state.buildTunnelMode() + let testSOCKSProxyMode = socksProxy.state.buildTunnelMode() let testConn = DatabaseConnection( name: network.name, host: testHost, @@ -468,6 +480,7 @@ final class ConnectionFormCoordinator { sshTunnelMode: testTunnelMode, cloudflareTunnelMode: testCloudflareMode, cloudSQLProxyMode: testCloudSQLProxyMode, + socksProxyMode: testSOCKSProxyMode, redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, @@ -481,8 +494,12 @@ final class ConnectionFormCoordinator { let connectionType = network.type let displayName = network.name.isEmpty ? network.host : network.name let sshState = ssh.state - let cloudflareState = cloudflareTunnel.state - let cloudSQLProxyState = cloudSQLProxy.state + let tunnelStates = TunnelFormStates( + ssh: ssh.state, + cloudflare: cloudflareTunnel.state, + cloudSQLProxy: cloudSQLProxy.state, + socksProxy: socksProxy.state + ) let sslClientKeyPassphrase = ssl.clientKeyPassphrase let sslClientKeyPath = ssl.clientKeyPath let additionalFieldValues = finalAdditionalFields @@ -491,11 +508,9 @@ final class ConnectionFormCoordinator { for: testConn.id, password: password, promptForPassword: promptForPassword, - sshState: sshState, + tunnelStates: tunnelStates, sslClientKeyPassphrase: sslClientKeyPassphrase, sslClientKeyPath: sslClientKeyPath, - cloudflareState: cloudflareState, - cloudSQLProxyState: cloudSQLProxyState, connectionType: connectionType, additionalFieldValues: additionalFieldValues ) @@ -604,18 +619,28 @@ final class ConnectionFormCoordinator { } } + private struct TunnelFormStates { + let ssh: SSHTunnelFormState + let cloudflare: CloudflareTunnelFormState + let cloudSQLProxy: CloudSQLProxyFormState + let socksProxy: SOCKSProxyFormState + } + private func persistTestSecrets( for testId: UUID, password: String, promptForPassword: Bool, - sshState: SSHTunnelFormState, + tunnelStates: TunnelFormStates, sslClientKeyPassphrase: String, sslClientKeyPath: String, - cloudflareState: CloudflareTunnelFormState, - cloudSQLProxyState: CloudSQLProxyFormState, connectionType: DatabaseType, additionalFieldValues: [String: String] ) { + let sshState = tunnelStates.ssh + let cloudflareState = tunnelStates.cloudflare + let cloudSQLProxyState = tunnelStates.cloudSQLProxy + let socksProxyState = tunnelStates.socksProxy + if !password.isEmpty && !promptForPassword { services.connectionStorage.savePassword(password, for: testId) } @@ -651,6 +676,10 @@ final class ConnectionFormCoordinator { ) } + if socksProxyState.enabled && !socksProxyState.password.isEmpty { + services.connectionStorage.saveSOCKSProxyPassword(socksProxyState.password, for: testId) + } + for field in services.pluginManager.additionalConnectionFields(for: connectionType) where field.isSecure { @@ -669,6 +698,7 @@ final class ConnectionFormCoordinator { services.connectionStorage.deleteCloudflareTokenId(for: testId) services.connectionStorage.deleteCloudflareTokenSecret(for: testId) services.connectionStorage.deleteCloudSQLProxyServiceAccountKey(for: testId) + services.connectionStorage.deleteSOCKSProxyPassword(for: testId) let secureFieldIds = services.pluginManager.additionalConnectionFields(for: network.type) .filter(\.isSecure).map(\.id) services.connectionStorage.deleteAllPluginSecureFields(for: testId, fieldIds: secureFieldIds) diff --git a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift index 31b9a36db..8c0d579e0 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormPane.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormPane.swift @@ -10,6 +10,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case ssh case cloudflareTunnel case cloudSQLProxy + case socksProxy case ssl case customization case advanced @@ -23,6 +24,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case .ssh: return String(localized: "SSH Tunnel") case .cloudflareTunnel: return String(localized: "Cloudflare Tunnel") case .cloudSQLProxy: return String(localized: "Cloud SQL Auth Proxy") + case .socksProxy: return String(localized: "SOCKS Proxy") case .ssl: return String(localized: "SSL/TLS") case .customization: return String(localized: "Customization") case .advanced: return String(localized: "Advanced") @@ -36,6 +38,7 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { case .ssh: return "lock.shield" case .cloudflareTunnel: return "cloud" case .cloudSQLProxy: return "cloud.fill" + case .socksProxy: return "arrow.triangle.swap" case .ssl: return "lock.fill" case .customization: return "paintbrush" case .advanced: return "gearshape.2" @@ -55,6 +58,8 @@ enum ConnectionFormPane: String, CaseIterable, Identifiable, Hashable { issues = coordinator.cloudflareTunnel.validationIssues case .cloudSQLProxy: issues = coordinator.cloudSQLProxy.validationIssues + case .socksProxy: + issues = coordinator.socksProxy.validationIssues case .ssl: issues = coordinator.ssl.validationIssues case .customization: diff --git a/TablePro/Views/ConnectionForm/ConnectionFormView.swift b/TablePro/Views/ConnectionForm/ConnectionFormView.swift index 578b07119..8b424f7b9 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormView.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormView.swift @@ -101,6 +101,8 @@ private struct ConnectionFormDetail: View { CloudflareTunnelPaneView(coordinator: coordinator) case .cloudSQLProxy: CloudSQLProxyPaneView(coordinator: coordinator) + case .socksProxy: + SOCKSProxyPaneView(coordinator: coordinator) case .ssl: SSLPaneView(coordinator: coordinator) case .customization: diff --git a/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift index 3603296c1..1d011bf14 100644 --- a/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/CloudSQLProxyPaneView.swift @@ -20,8 +20,8 @@ struct CloudSQLProxyPaneView: View { } if coordinator.cloudSQLProxy.state.enabled { - if coordinator.ssh.state.enabled || coordinator.cloudflareTunnel.state.enabled { - mutualExclusivitySection + if !coordinator.otherEnabledTunnels(excluding: .cloudSQLProxy).isEmpty { + TunnelExclusivityBanner(coordinator: coordinator, currentKind: .cloudSQLProxy) } instanceSection authenticationSection @@ -36,27 +36,6 @@ struct CloudSQLProxyPaneView: View { // MARK: - Sections - @ViewBuilder - private var mutualExclusivitySection: some View { - Section { - Label( - String(localized: "A connection can use one connection method at a time. Disable the other one to use the Cloud SQL Auth Proxy."), - systemImage: "exclamationmark.triangle.fill" - ) - .foregroundStyle(.orange) - if coordinator.ssh.state.enabled { - Button("Disable SSH Tunnel") { - coordinator.ssh.state.disable() - } - } - if coordinator.cloudflareTunnel.state.enabled { - Button("Disable Cloudflare Tunnel") { - coordinator.cloudflareTunnel.state.enabled = false - } - } - } - } - private var instanceSection: some View { Section { TextField( diff --git a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift index b77870a7f..49450f797 100644 --- a/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/CloudflareTunnelPaneView.swift @@ -20,8 +20,8 @@ struct CloudflareTunnelPaneView: View { } if coordinator.cloudflareTunnel.state.enabled { - if coordinator.ssh.state.enabled { - mutualExclusivitySection + if !coordinator.otherEnabledTunnels(excluding: .cloudflare).isEmpty { + TunnelExclusivityBanner(coordinator: coordinator, currentKind: .cloudflare) } hostnameSection authenticationSection @@ -35,19 +35,6 @@ struct CloudflareTunnelPaneView: View { // MARK: - Sections - private var mutualExclusivitySection: some View { - Section { - Label( - String(localized: "A connection can use one tunnel at a time. Disable the SSH Tunnel to use a Cloudflare Tunnel."), - systemImage: "exclamationmark.triangle.fill" - ) - .foregroundStyle(.orange) - Button("Disable SSH Tunnel") { - coordinator.ssh.state.disable() - } - } - } - private var hostnameSection: some View { Section(String(localized: "Access Application")) { TextField( diff --git a/TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift b/TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift new file mode 100644 index 000000000..ae0d0596d --- /dev/null +++ b/TablePro/Views/ConnectionForm/Panes/SOCKSProxyPaneView.swift @@ -0,0 +1,58 @@ +// +// SOCKSProxyPaneView.swift +// TablePro +// + +import SwiftUI + +struct SOCKSProxyPaneView: View { + @Bindable var coordinator: ConnectionFormCoordinator + + var body: some View { + Form { + Section { + Toggle(String(localized: "Enable SOCKS Proxy"), isOn: $coordinator.socksProxy.state.enabled) + } footer: { + Text("Routes this connection through a SOCKS5 proxy. The database hostname is resolved by the proxy, so names that only resolve behind it still work.") + } + + if coordinator.socksProxy.state.enabled { + if !coordinator.otherEnabledTunnels(excluding: .socksProxy).isEmpty { + TunnelExclusivityBanner(coordinator: coordinator, currentKind: .socksProxy) + } + serverSection + credentialsSection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + private var serverSection: some View { + Section(String(localized: "Proxy Server")) { + TextField( + String(localized: "Host"), + text: $coordinator.socksProxy.state.host, + prompt: Text(verbatim: "proxy.example.com") + ) + .autocorrectionDisabled() + TextField( + String(localized: "Port"), + text: $coordinator.socksProxy.state.port, + prompt: Text(verbatim: "1080") + ) + } + } + + private var credentialsSection: some View { + Section { + TextField(String(localized: "Username"), text: $coordinator.socksProxy.state.username) + .autocorrectionDisabled() + SecureField(String(localized: "Password"), text: $coordinator.socksProxy.state.password) + } header: { + Text("Credentials") + } footer: { + Text("Leave blank to connect without authentication. The password is stored in the macOS Keychain.") + } + } +} diff --git a/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift b/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift index 71fbb2291..50076c14f 100644 --- a/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/SSHPaneView.swift @@ -11,7 +11,8 @@ struct SSHPaneView: View { var body: some View { ConnectionSSHTunnelView( sshState: $coordinator.ssh.state, - databaseType: coordinator.network.type + databaseType: coordinator.network.type, + coordinator: coordinator ) } } diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift index 24bdba331..6af3793ee 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudSQLProxyPaneViewModel.swift @@ -41,12 +41,12 @@ final class CloudSQLProxyPaneViewModel { issues.append(String(localized: "A service account key is required")) } - if coordinator?.value?.ssh.state.enabled == true { - issues.append(String(localized: "Cannot use SSH Tunnel and Cloud SQL Auth Proxy at the same time")) - } - - if coordinator?.value?.cloudflareTunnel.state.enabled == true { - issues.append(String(localized: "Cannot use Cloudflare Tunnel and Cloud SQL Auth Proxy at the same time")) + for other in coordinator?.value?.otherEnabledTunnels(excluding: .cloudSQLProxy) ?? [] { + issues.append(String( + format: String(localized: "Cannot use %@ and %@ at the same time"), + other.kind.displayName, + ConnectionTunnelKind.cloudSQLProxy.displayName + )) } return issues diff --git a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift index c7994e4bd..53fed970f 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CloudflareTunnelPaneViewModel.swift @@ -43,12 +43,12 @@ final class CloudflareTunnelPaneViewModel { } } - if coordinator?.value?.ssh.state.enabled == true { - issues.append(String(localized: "Cannot use SSH Tunnel and Cloudflare Tunnel at the same time")) - } - - if coordinator?.value?.cloudSQLProxy.state.enabled == true { - issues.append(String(localized: "Cannot use Cloud SQL Auth Proxy and Cloudflare Tunnel at the same time")) + for other in coordinator?.value?.otherEnabledTunnels(excluding: .cloudflare) ?? [] { + issues.append(String( + format: String(localized: "Cannot use %@ and %@ at the same time"), + other.kind.displayName, + ConnectionTunnelKind.cloudflare.displayName + )) } return issues diff --git a/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift new file mode 100644 index 000000000..54d973085 --- /dev/null +++ b/TablePro/Views/ConnectionForm/ViewModels/SOCKSProxyPaneViewModel.swift @@ -0,0 +1,51 @@ +// +// SOCKSProxyPaneViewModel.swift +// TablePro +// + +import Foundation + +@Observable +@MainActor +final class SOCKSProxyPaneViewModel { + var state = SOCKSProxyFormState() + + var coordinator: WeakCoordinatorRef? + + var validationIssues: [String] { + guard state.enabled else { return [] } + var issues: [String] = [] + + if state.host.trimmingCharacters(in: .whitespaces).isEmpty { + issues.append(String(localized: "SOCKS proxy host is required")) + } + + let portIsValid = Int(state.port).map { (1...65_535).contains($0) } ?? false + if !portIsValid { + issues.append(String(localized: "SOCKS proxy port must be between 1 and 65535")) + } + + for other in coordinator?.value?.otherEnabledTunnels(excluding: .socksProxy) ?? [] { + issues.append(String( + format: String(localized: "Cannot use %@ and %@ at the same time"), + other.kind.displayName, + ConnectionTunnelKind.socksProxy.displayName + )) + } + + return issues + } + + func load(from connection: DatabaseConnection, storage: ConnectionStorage) { + state.load(from: connection) + state.password = storage.loadSOCKSProxyPassword(for: connection.id) ?? "" + } + + func save(to connectionId: UUID, storage: ConnectionStorage) { + guard state.enabled, !state.password.isEmpty else { + storage.deleteSOCKSProxyPassword(for: connectionId) + return + } + storage.saveSOCKSProxyPassword(state.password, for: connectionId) + } +} diff --git a/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift index e99db1fae..88f74c851 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/SSHPaneViewModel.swift @@ -15,11 +15,12 @@ final class SSHPaneViewModel { var validationIssues: [String] { guard state.enabled else { return [] } var issues: [String] = [] - if coordinator?.value?.cloudflareTunnel.state.enabled == true { - issues.append(String(localized: "Cannot use Cloudflare Tunnel and SSH Tunnel at the same time")) - } - if coordinator?.value?.cloudSQLProxy.state.enabled == true { - issues.append(String(localized: "Cannot use Cloud SQL Auth Proxy and SSH Tunnel at the same time")) + for other in coordinator?.value?.otherEnabledTunnels(excluding: .ssh) ?? [] { + issues.append(String( + format: String(localized: "Cannot use %@ and %@ at the same time"), + other.kind.displayName, + ConnectionTunnelKind.ssh.displayName + )) } guard state.profileId == nil else { return issues } if state.host.trimmingCharacters(in: .whitespaces).isEmpty { diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index d38270534..76010950a 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -93,6 +93,43 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.first?.name == "Round Trip Test") } + @Test("duplicating a connection carries tunnel modes and their secrets") + func duplicateCarriesTunnelModesAndSecrets() { + var connection = DatabaseConnection(name: "Tunnels", type: .postgresql) + connection.cloudflareTunnelMode = .inline(CloudflareConfiguration(accessHostname: "db.example.com")) + connection.cloudSQLProxyMode = .inline(CloudSQLProxyConfiguration(instanceConnectionName: "p:r:i")) + connection.socksProxyMode = .inline(SOCKSProxyConfiguration(host: "proxy.example.com", username: "u")) + storage.addConnection(connection) + storage.saveCloudflareTokenId("token-id", for: connection.id) + storage.saveCloudflareTokenSecret("token-secret", for: connection.id) + storage.saveCloudSQLProxyServiceAccountKey("{\"type\":\"service_account\"}", for: connection.id) + storage.saveSOCKSProxyPassword("proxy-pw", for: connection.id) + + let duplicate = storage.duplicateConnection(connection) + + #expect(duplicate.cloudflareTunnelMode == connection.cloudflareTunnelMode) + #expect(duplicate.cloudSQLProxyMode == connection.cloudSQLProxyMode) + #expect(duplicate.socksProxyMode == connection.socksProxyMode) + #expect(storage.loadCloudflareTokenId(for: duplicate.id) == "token-id") + #expect(storage.loadCloudflareTokenSecret(for: duplicate.id) == "token-secret") + #expect(storage.loadCloudSQLProxyServiceAccountKey(for: duplicate.id) == "{\"type\":\"service_account\"}") + #expect(storage.loadSOCKSProxyPassword(for: duplicate.id) == "proxy-pw") + + let reloaded = storage.loadConnections().first { $0.id == duplicate.id } + #expect(reloaded?.socksProxyMode == connection.socksProxyMode) + } + + @Test("deleting a connection removes its SOCKS proxy password") + func deleteRemovesSOCKSProxyPassword() { + let connection = DatabaseConnection(name: "Proxied", type: .postgresql) + storage.addConnection(connection) + storage.saveSOCKSProxyPassword("proxy-pw", for: connection.id) + + storage.deleteConnection(connection) + + #expect(storage.loadSOCKSProxyPassword(for: connection.id) == nil) + } + @Test("duplicating a connection preserves its password source") func duplicatePreservesPasswordSource() { var connection = DatabaseConnection(name: "Source", type: .postgresql) diff --git a/TableProTests/Models/ConnectionTunnelKindTests.swift b/TableProTests/Models/ConnectionTunnelKindTests.swift index fcfbd5d43..02632c6d1 100644 --- a/TableProTests/Models/ConnectionTunnelKindTests.swift +++ b/TableProTests/Models/ConnectionTunnelKindTests.swift @@ -10,7 +10,12 @@ import Testing @Suite("Connection tunnel kind") struct ConnectionTunnelKindTests { - private func connection(ssh: Bool, cloudflare: Bool, proxy: Bool) -> DatabaseConnection { + private func connection( + ssh: Bool = false, + cloudflare: Bool = false, + cloudSQLProxy: Bool = false, + socksProxy: Bool = false + ) -> DatabaseConnection { DatabaseConnection( name: "T", type: .postgresql, @@ -18,39 +23,51 @@ struct ConnectionTunnelKindTests { cloudflareTunnelMode: cloudflare ? .inline(CloudflareConfiguration(accessHostname: "db.example.com")) : .disabled, - cloudSQLProxyMode: proxy + cloudSQLProxyMode: cloudSQLProxy ? .inline(CloudSQLProxyConfiguration(instanceConnectionName: "p:r:i")) + : .disabled, + socksProxyMode: socksProxy + ? .inline(SOCKSProxyConfiguration(host: "proxy.example.com")) : .disabled ) } @Test("no tunnel has no active kind") func none() { - let connection = connection(ssh: false, cloudflare: false, proxy: false) + let connection = connection() #expect(connection.enabledTunnelKinds.isEmpty) #expect(connection.activeTunnelKind == nil) } @Test("a single enabled tunnel resolves to that kind") func single() { - #expect(connection(ssh: true, cloudflare: false, proxy: false).activeTunnelKind == .ssh) - #expect(connection(ssh: false, cloudflare: true, proxy: false).activeTunnelKind == .cloudflare) - #expect(connection(ssh: false, cloudflare: false, proxy: true).activeTunnelKind == .cloudSQLProxy) + #expect(connection(ssh: true).activeTunnelKind == .ssh) + #expect(connection(cloudflare: true).activeTunnelKind == .cloudflare) + #expect(connection(cloudSQLProxy: true).activeTunnelKind == .cloudSQLProxy) + #expect(connection(socksProxy: true).activeTunnelKind == .socksProxy) } - @Test("two enabled tunnels are a conflict with no active kind") - func twoConflict() { - for combo in [(true, true, false), (true, false, true), (false, true, true)] { - let connection = connection(ssh: combo.0, cloudflare: combo.1, proxy: combo.2) - #expect(connection.enabledTunnelKinds.count == 2) - #expect(connection.activeTunnelKind == nil) - } - } + @Test("every combination of two or more enabled tunnels is a conflict") + func allCombinations() { + for mask in 0..<16 { + let ssh = mask & 1 != 0 + let cloudflare = mask & 2 != 0 + let cloudSQLProxy = mask & 4 != 0 + let socksProxy = mask & 8 != 0 + let enabledCount = [ssh, cloudflare, cloudSQLProxy, socksProxy].filter { $0 }.count - @Test("all three enabled is a conflict with no active kind") - func threeConflict() { - let connection = connection(ssh: true, cloudflare: true, proxy: true) - #expect(connection.enabledTunnelKinds.count == 3) - #expect(connection.activeTunnelKind == nil) + let connection = connection( + ssh: ssh, + cloudflare: cloudflare, + cloudSQLProxy: cloudSQLProxy, + socksProxy: socksProxy + ) + #expect(connection.enabledTunnelKinds.count == enabledCount) + if enabledCount == 1 { + #expect(connection.activeTunnelKind == connection.enabledTunnelKinds.first) + } else { + #expect(connection.activeTunnelKind == nil) + } + } } } diff --git a/TableProTests/SOCKS/FakeSOCKS5Server.swift b/TableProTests/SOCKS/FakeSOCKS5Server.swift new file mode 100644 index 000000000..d2515e098 --- /dev/null +++ b/TableProTests/SOCKS/FakeSOCKS5Server.swift @@ -0,0 +1,200 @@ +// +// FakeSOCKS5Server.swift +// TableProTests +// + +import Foundation +import Network +import os + +final class FakeSOCKS5Server: @unchecked Sendable { + enum Behavior: Sendable { + case echo + case rejectAuth + case neverReply + } + + struct ConnectRequest: Sendable { + let addressType: UInt8 + let address: Data + let port: UInt16 + } + + private struct CapturedState { + var connectRequests: [ConnectRequest] = [] + var sawAuthNegotiation = false + } + + private let queue = DispatchQueue(label: "com.TablePro.tests.FakeSOCKS5Server") + private let behavior: Behavior + private let requiredUsername: String? + private let requiredPassword: String? + private let captured = OSAllocatedUnfairLock(initialState: CapturedState()) + private var listener: NWListener? + + private(set) var port: Int = 0 + + var capturedConnectRequest: ConnectRequest? { + captured.withLock { $0.connectRequests.last } + } + + var sawAuthNegotiation: Bool { + captured.withLock { $0.sawAuthNegotiation } + } + + init(behavior: Behavior = .echo, username: String? = nil, password: String? = nil) { + self.behavior = behavior + self.requiredUsername = username + self.requiredPassword = password + } + + func start() async throws { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: .any) + let listener = try NWListener(using: parameters) + self.listener = listener + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + port = try await withCheckedThrowingContinuation { continuation in + let resumed = OSAllocatedUnfairLock(initialState: false) + listener.stateUpdateHandler = { state in + let isTerminal: Bool + switch state { + case .failed, .cancelled: isTerminal = true + default: isTerminal = false + } + let shouldResume = resumed.withLock { done -> Bool in + guard !done, state == .ready || isTerminal else { return false } + done = true + return true + } + guard shouldResume else { return } + if case .ready = state, let assignedPort = listener.port { + continuation.resume(returning: Int(assignedPort.rawValue)) + } else { + continuation.resume(throwing: POSIXError(.EADDRINUSE)) + } + } + listener.start(queue: queue) + } + } + + func stop() { + listener?.cancel() + listener = nil + } + + private func handle(_ connection: NWConnection) { + connection.start(queue: queue) + guard behavior != .neverReply else { return } + Task { + do { + try await self.negotiate(connection) + } catch { + connection.cancel() + } + } + } + + private func negotiate(_ connection: NWConnection) async throws { + let greeting = try await read(connection, count: 2) + guard greeting[0] == 0x05 else { throw POSIXError(.EPROTO) } + _ = try await read(connection, count: Int(greeting[1])) + + if requiredUsername != nil || behavior == .rejectAuth { + try await send(connection, Data([0x05, 0x02])) + try await performAuthSubnegotiation(connection) + } else { + try await send(connection, Data([0x05, 0x00])) + } + + let request = try await readConnectRequest(connection) + captured.withLock { $0.connectRequests.append(request) } + try await send(connection, Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + echoLoop(connection) + } + + private func performAuthSubnegotiation(_ connection: NWConnection) async throws { + let header = try await read(connection, count: 2) + let username = try await read(connection, count: Int(header[1])) + let passwordLength = try await read(connection, count: 1) + let password = try await read(connection, count: Int(passwordLength[0])) + captured.withLock { $0.sawAuthNegotiation = true } + + let matches = String(data: username, encoding: .utf8) == requiredUsername + && String(data: password, encoding: .utf8) == requiredPassword + guard behavior != .rejectAuth, matches else { + try await send(connection, Data([0x01, 0x01])) + connection.cancel() + throw POSIXError(.EAUTH) + } + try await send(connection, Data([0x01, 0x00])) + } + + private func readConnectRequest(_ connection: NWConnection) async throws -> ConnectRequest { + let header = try await read(connection, count: 4) + guard header[0] == 0x05, header[1] == 0x01 else { throw POSIXError(.EPROTO) } + let addressType = header[3] + let address: Data + switch addressType { + case 0x01: + address = try await read(connection, count: 4) + case 0x03: + let length = try await read(connection, count: 1) + address = try await read(connection, count: Int(length[0])) + case 0x04: + address = try await read(connection, count: 16) + default: + throw POSIXError(.EPROTO) + } + let portBytes = try await read(connection, count: 2) + let port = UInt16(portBytes[0]) << 8 | UInt16(portBytes[1]) + return ConnectRequest(addressType: addressType, address: address, port: port) + } + + private func echoLoop(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in + guard let self, error == nil, !isComplete else { + connection.cancel() + return + } + if let data, !data.isEmpty { + connection.send(content: data, completion: .contentProcessed { sendError in + guard sendError == nil else { + connection.cancel() + return + } + self.echoLoop(connection) + }) + } else { + self.echoLoop(connection) + } + } + } + + private func read(_ connection: NWConnection, count: Int) async throws -> Data { + guard count > 0 else { return Data() } + return try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: count, maximumLength: count) { data, _, _, error in + if let data, data.count == count { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? NWError.posix(.ECONNRESET)) + } + } + } + } + + private func send(_ connection: NWConnection, _ data: Data) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } +} diff --git a/TableProTests/SOCKS/SOCKSProxyManagerTests.swift b/TableProTests/SOCKS/SOCKSProxyManagerTests.swift new file mode 100644 index 000000000..1c0231be8 --- /dev/null +++ b/TableProTests/SOCKS/SOCKSProxyManagerTests.swift @@ -0,0 +1,350 @@ +// +// SOCKSProxyManagerTests.swift +// TableProTests +// + +import Foundation +import Network +import os +import Testing + +@testable import TablePro + +@Suite("SOCKS proxy manager", .serialized) +struct SOCKSProxyManagerTests { + private func config(port: Int, username: String = "") -> SOCKSProxyConfiguration { + SOCKSProxyConfiguration(host: "127.0.0.1", port: port, username: username) + } + + @Test("invalid configuration is rejected without touching the network") + func invalidConfiguration() async { + let manager = SOCKSProxyManager(connectTimeout: 1) + await #expect(throws: SOCKSProxyError.invalidConfiguration) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: SOCKSProxyConfiguration(host: "", port: 1_080), + password: nil, + targetHost: "db.example.com", + targetPort: 5_432 + ) + } + await #expect(throws: SOCKSProxyError.invalidConfiguration) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: SOCKSProxyConfiguration(host: "127.0.0.1", port: 0), + password: nil, + targetHost: "db.example.com", + targetPort: 5_432 + ) + } + await #expect(throws: SOCKSProxyError.invalidConfiguration) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: SOCKSProxyConfiguration(host: "127.0.0.1", port: 1_080), + password: nil, + targetHost: "db.example.com", + targetPort: 70_000 + ) + } + } + + @Test("bytes round-trip through the proxy to the target") + func byteRoundTrip() async throws { + let server = FakeSOCKS5Server(behavior: .echo) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 5) + let connectionId = UUID() + let localPort = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } + + let client = try await TestTCPClient.connect(port: localPort) + defer { client.cancel() } + let payload = Data("SELECT 1".utf8) + try await client.send(payload) + let echoed = try await client.receive(count: payload.count) + #expect(echoed == payload) + #expect(await manager.hasTunnel(connectionId: connectionId)) + } + + @Test("the target hostname reaches the proxy unresolved") + func remoteDNS() async throws { + let server = FakeSOCKS5Server(behavior: .echo) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 5) + let connectionId = UUID() + _ = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "only-resolves-behind-the-bastion.internal", + targetPort: 3_306 + ) + defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } + + let request = try #require(server.capturedConnectRequest) + #expect(request.addressType == 0x03) + #expect(request.address == Data("only-resolves-behind-the-bastion.internal".utf8)) + #expect(request.port == 3_306) + } + + @Test("username and password are negotiated per RFC 1929") + func credentialNegotiation() async throws { + let server = FakeSOCKS5Server(behavior: .echo, username: "tester", password: "s3cret") + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 5) + let connectionId = UUID() + _ = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port, username: "tester"), + password: "s3cret", + targetHost: "db.internal.example", + targetPort: 5_432 + ) + defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } + + #expect(server.sawAuthNegotiation) + } + + @Test("rejected credentials fail tunnel creation") + func rejectedCredentials() async throws { + let server = FakeSOCKS5Server(behavior: .rejectAuth, username: "tester", password: "expected") + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 2) + let connectionId = UUID() + await #expect(throws: SOCKSProxyError.self) { + _ = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port, username: "tester"), + password: "wrong", + targetHost: "db.internal.example", + targetPort: 5_432 + ) + } + #expect(await !manager.hasTunnel(connectionId: connectionId)) + } + + @Test("an unreachable proxy times out within the deadline") + func proxyUnreachable() async throws { + let freePort = try #require(LoopbackPort.allocateFree()) + let manager = SOCKSProxyManager(connectTimeout: 1) + let started = Date() + await #expect(throws: SOCKSProxyError.self) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: config(port: freePort), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + } + #expect(Date().timeIntervalSince(started) < 10) + } + + @Test("a silent proxy times out within the deadline") + func silentProxyTimesOut() async throws { + let server = FakeSOCKS5Server(behavior: .neverReply) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 1) + await #expect(throws: SOCKSProxyError.connectTimedOut(proxyHost: "127.0.0.1", proxyPort: server.port)) { + _ = try await manager.createTunnel( + connectionId: UUID(), + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + } + } + + @Test("cancelling tunnel creation returns promptly") + func cancellationDuringCreate() async throws { + let server = FakeSOCKS5Server(behavior: .neverReply) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 30) + let connectionId = UUID() + let creation = Task { + try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + } + try await Task.sleep(nanoseconds: 200_000_000) + let started = Date() + creation.cancel() + await #expect(throws: (any Error).self) { _ = try await creation.value } + #expect(Date().timeIntervalSince(started) < 5) + #expect(await !manager.hasTunnel(connectionId: connectionId)) + } + + @Test("two concurrent clients relay independently through one tunnel") + func concurrentClients() async throws { + let server = FakeSOCKS5Server(behavior: .echo) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 5) + let connectionId = UUID() + let localPort = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } + + async let first: Data = { + let client = try await TestTCPClient.connect(port: localPort) + defer { client.cancel() } + try await client.send(Data("first-payload".utf8)) + return try await client.receive(count: "first-payload".utf8.count) + }() + async let second: Data = { + let client = try await TestTCPClient.connect(port: localPort) + defer { client.cancel() } + try await client.send(Data("second".utf8)) + return try await client.receive(count: "second".utf8.count) + }() + + let results = try await (first, second) + #expect(results.0 == Data("first-payload".utf8)) + #expect(results.1 == Data("second".utf8)) + #expect(await manager.hasTunnel(connectionId: connectionId)) + } + + @Test("closing the tunnel frees the local port") + func closeTunnelFreesPort() async throws { + let server = FakeSOCKS5Server(behavior: .echo) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 5) + let connectionId = UUID() + let localPort = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + #expect(await manager.hasTunnel(connectionId: connectionId)) + #expect(await manager.getLocalPort(connectionId: connectionId) == localPort) + + try await manager.closeTunnel(connectionId: connectionId) + #expect(await !manager.hasTunnel(connectionId: connectionId)) + #expect(await manager.getLocalPort(connectionId: connectionId) == nil) + #expect(await !LoopbackPort.isReachable(host: "127.0.0.1", port: localPort)) + } + + @Test("recreating a tunnel for the same connection replaces the old one") + func recreateReplacesExisting() async throws { + let server = FakeSOCKS5Server(behavior: .echo) + try await server.start() + defer { server.stop() } + + let manager = SOCKSProxyManager(connectTimeout: 5) + let connectionId = UUID() + let firstPort = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + let secondPort = try await manager.createTunnel( + connectionId: connectionId, + config: config(port: server.port), + password: nil, + targetHost: "db.internal.example", + targetPort: 5_432 + ) + defer { Task { try await manager.closeTunnel(connectionId: connectionId) } } + + #expect(await manager.getLocalPort(connectionId: connectionId) == secondPort) + #expect(await !LoopbackPort.isReachable(host: "127.0.0.1", port: firstPort)) + } +} + +private struct TestTCPClient { + let connection: NWConnection + + static func connect(port: Int) async throws -> TestTCPClient { + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { throw POSIXError(.EINVAL) } + let connection = NWConnection(host: "127.0.0.1", port: nwPort, using: .tcp) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let finish: (Result) -> Void = { result in + let shouldResume = resumed.withLock { done -> Bool in + guard !done else { return false } + done = true + return true + } + guard shouldResume else { return } + continuation.resume(with: result) + } + connection.stateUpdateHandler = { state in + switch state { + case .ready: + finish(.success(())) + case .failed(let error): + finish(.failure(error)) + case .cancelled: + finish(.failure(CancellationError())) + default: + break + } + } + connection.start(queue: .global(qos: .userInitiated)) + } + return TestTCPClient(connection: connection) + } + + func send(_ data: Data) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + func receive(count: Int) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: count, maximumLength: count) { data, _, _, error in + if let data, data.count == count { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? NWError.posix(.ECONNRESET)) + } + } + } + } + + func cancel() { + connection.cancel() + } +} diff --git a/TableProTests/SOCKS/SOCKSProxyModelTests.swift b/TableProTests/SOCKS/SOCKSProxyModelTests.swift new file mode 100644 index 000000000..056cd4df5 --- /dev/null +++ b/TableProTests/SOCKS/SOCKSProxyModelTests.swift @@ -0,0 +1,132 @@ +// +// SOCKSProxyModelTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("SOCKS proxy model") +struct SOCKSProxyModelTests { + @Test("SOCKSProxyConfiguration round-trips through Codable") + func configurationRoundTrip() throws { + let config = SOCKSProxyConfiguration(host: "proxy.example.com", port: 9_150, username: "tester") + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(SOCKSProxyConfiguration.self, from: data) + #expect(decoded == config) + } + + @Test("SOCKSProxyConfiguration decodes missing fields to defaults") + func configurationDecodesDefaults() throws { + let decoded = try JSONDecoder().decode(SOCKSProxyConfiguration.self, from: Data("{}".utf8)) + #expect(decoded.host.isEmpty) + #expect(decoded.port == 1_080) + #expect(decoded.username.isEmpty) + } + + @Test("SOCKSProxyMode encodes inline config and decodes back") + func modeRoundTrip() throws { + let mode = SOCKSProxyMode.inline(SOCKSProxyConfiguration(host: "proxy.example.com")) + let data = try JSONEncoder().encode(mode) + let decoded = try JSONDecoder().decode(SOCKSProxyMode.self, from: data) + #expect(decoded == mode) + } + + @Test("SOCKSProxyMode disabled round-trips") + func disabledRoundTrip() throws { + let data = try JSONEncoder().encode(SOCKSProxyMode.disabled) + let decoded = try JSONDecoder().decode(SOCKSProxyMode.self, from: data) + #expect(decoded == .disabled) + } + + @Test("DatabaseConnection preserves socksProxyMode through Codable") + func connectionRoundTrip() throws { + let connection = DatabaseConnection( + name: "Proxied", + host: "db.internal.example", + port: 5_432, + type: .postgresql, + socksProxyMode: .inline(SOCKSProxyConfiguration(host: "proxy.example.com", port: 1_080, username: "u")) + ) + + let data = try JSONEncoder().encode(connection) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + + #expect(decoded.socksProxyMode == connection.socksProxyMode) + #expect(decoded.isSOCKSProxyEnabled) + #expect(decoded.resolvedSOCKSProxyConfig?.host == "proxy.example.com") + } + + @Test("a disabled mode is omitted from the encoded connection") + func disabledModeOmitted() throws { + let connection = DatabaseConnection(name: "Plain", type: .mysql) + let data = try JSONEncoder().encode(connection) + + let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["socksProxyMode"] == nil) + + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(decoded.socksProxyMode == .disabled) + #expect(!decoded.isSOCKSProxyEnabled) + #expect(decoded.resolvedSOCKSProxyConfig == nil) + } + + @Test("configuration validation requires a host and an in-range port") + func configurationValidation() { + #expect(SOCKSProxyConfiguration(host: "proxy.example.com", port: 1_080).isValid) + #expect(SOCKSProxyConfiguration(host: "proxy.example.com", port: 1).isValid) + #expect(SOCKSProxyConfiguration(host: "proxy.example.com", port: 65_535).isValid) + #expect(!SOCKSProxyConfiguration(host: "", port: 1_080).isValid) + #expect(!SOCKSProxyConfiguration(host: " ", port: 1_080).isValid) + #expect(!SOCKSProxyConfiguration(host: "proxy.example.com", port: 0).isValid) + #expect(!SOCKSProxyConfiguration(host: "proxy.example.com", port: 65_536).isValid) + } + + @Test("StoredConnection round-trips socksProxyMode") + func storedConnectionRoundTrip() throws { + let connection = DatabaseConnection( + name: "Proxied", + host: "db.internal.example", + port: 5_432, + type: .postgresql, + socksProxyMode: .inline(SOCKSProxyConfiguration(host: "proxy.example.com", port: 1_081)) + ) + + let stored = StoredConnection(from: connection) + let data = try JSONEncoder().encode(stored) + let decodedStored = try JSONDecoder().decode(StoredConnection.self, from: data) + let restored = decodedStored.toConnection() + + #expect(restored.socksProxyMode == connection.socksProxyMode) + } + + @Test("SOCKS form state builds the tunnel mode and loads it back") + func formStateRoundTrip() { + var state = SOCKSProxyFormState() + state.enabled = true + state.host = " proxy.example.com " + state.port = "9150" + state.username = "tester" + + guard case .inline(let config) = state.buildTunnelMode() else { + Issue.record("Expected an inline mode") + return + } + #expect(config.host == "proxy.example.com") + #expect(config.port == 9_150) + #expect(config.username == "tester") + + var reloaded = SOCKSProxyFormState() + reloaded.load(from: DatabaseConnection(name: "P", type: .postgresql, socksProxyMode: .inline(config))) + #expect(reloaded.enabled) + #expect(reloaded.host == "proxy.example.com") + #expect(reloaded.port == "9150") + #expect(reloaded.username == "tester") + + var disabled = SOCKSProxyFormState() + disabled.load(from: DatabaseConnection(name: "P", type: .postgresql)) + #expect(!disabled.enabled) + } +} diff --git a/TableProTests/SOCKS/SOCKSProxyPaneViewModelTests.swift b/TableProTests/SOCKS/SOCKSProxyPaneViewModelTests.swift new file mode 100644 index 000000000..09f5942bb --- /dev/null +++ b/TableProTests/SOCKS/SOCKSProxyPaneViewModelTests.swift @@ -0,0 +1,57 @@ +// +// SOCKSProxyPaneViewModelTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("SOCKS proxy pane view model") +struct SOCKSProxyPaneViewModelTests { + @Test("disabled reports no issues") + func disabledNoIssues() { + let viewModel = SOCKSProxyPaneViewModel() + viewModel.state.enabled = false + #expect(viewModel.validationIssues.isEmpty) + } + + @Test("enabled requires a host") + func requiresHost() { + let viewModel = SOCKSProxyPaneViewModel() + viewModel.state.enabled = true + viewModel.state.host = " " + #expect(viewModel.validationIssues.contains { $0.localizedCaseInsensitiveContains("host") }) + + viewModel.state.host = "proxy.example.com" + #expect(viewModel.validationIssues.isEmpty) + } + + @Test("port must be in range") + func portRange() { + let viewModel = SOCKSProxyPaneViewModel() + viewModel.state.enabled = true + viewModel.state.host = "proxy.example.com" + + viewModel.state.port = "70000" + #expect(viewModel.validationIssues.contains { $0.localizedCaseInsensitiveContains("port") }) + + viewModel.state.port = "not-a-port" + #expect(viewModel.validationIssues.contains { $0.localizedCaseInsensitiveContains("port") }) + + viewModel.state.port = "1080" + #expect(viewModel.validationIssues.isEmpty) + } + + @Test("credentials are optional") + func credentialsOptional() { + let viewModel = SOCKSProxyPaneViewModel() + viewModel.state.enabled = true + viewModel.state.host = "proxy.example.com" + viewModel.state.username = "" + viewModel.state.password = "" + #expect(viewModel.validationIssues.isEmpty) + } +} diff --git a/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift b/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift new file mode 100644 index 000000000..a6e83eaf0 --- /dev/null +++ b/TableProTests/ViewModels/ConnectionFormTunnelExclusivityTests.swift @@ -0,0 +1,78 @@ +// +// ConnectionFormTunnelExclusivityTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("Connection form tunnel exclusivity") +struct ConnectionFormTunnelExclusivityTests { + private func coordinator(enabled: Set) -> ConnectionFormCoordinator { + let coordinator = ConnectionFormCoordinator(connectionId: nil) + coordinator.ssh.state.enabled = enabled.contains(.ssh) + coordinator.cloudflareTunnel.state.enabled = enabled.contains(.cloudflare) + coordinator.cloudSQLProxy.state.enabled = enabled.contains(.cloudSQLProxy) + coordinator.socksProxy.state.enabled = enabled.contains(.socksProxy) + return coordinator + } + + @Test("no enabled tunnels yields an empty list") + func emptyWhenAllDisabled() { + let coordinator = coordinator(enabled: []) + #expect(coordinator.enabledTunnels.isEmpty) + for kind in ConnectionTunnelKind.allCases { + #expect(coordinator.otherEnabledTunnels(excluding: kind).isEmpty) + } + } + + @Test("every pair of enabled tunnels warns in both directions") + func pairwiseConflicts() { + let kinds = ConnectionTunnelKind.allCases + for first in kinds { + for second in kinds where second != first { + let coordinator = coordinator(enabled: [first, second]) + #expect(coordinator.otherEnabledTunnels(excluding: first).map(\.kind) == [second]) + #expect(coordinator.otherEnabledTunnels(excluding: second).map(\.kind) == [first]) + } + } + } + + @Test("all four enabled reports the three others per kind") + func allEnabled() { + let coordinator = coordinator(enabled: Set(ConnectionTunnelKind.allCases)) + #expect(coordinator.enabledTunnels.count == 4) + for kind in ConnectionTunnelKind.allCases { + let others = coordinator.otherEnabledTunnels(excluding: kind) + #expect(others.count == 3) + #expect(!others.map(\.kind).contains(kind)) + } + } + + @Test("the disable action turns the other tunnel off") + func disableAction() { + let coordinator = coordinator(enabled: [.ssh, .socksProxy]) + let others = coordinator.otherEnabledTunnels(excluding: .socksProxy) + #expect(others.map(\.kind) == [.ssh]) + others.first?.disable() + #expect(!coordinator.ssh.state.enabled) + #expect(coordinator.otherEnabledTunnels(excluding: .socksProxy).isEmpty) + } + + @Test("each pane view model reports cross-tunnel conflicts") + func paneViewModelsReportConflicts() { + let coordinator = coordinator(enabled: [.ssh, .cloudflare, .cloudSQLProxy, .socksProxy]) + coordinator.socksProxy.state.host = "proxy.example.com" + coordinator.cloudflareTunnel.state.accessHostname = "db.example.com" + coordinator.cloudSQLProxy.state.instanceConnectionName = "p:r:i" + coordinator.ssh.state.host = "bastion.example.com" + + #expect(coordinator.ssh.validationIssues.count >= 3) + #expect(coordinator.cloudflareTunnel.validationIssues.count >= 3) + #expect(coordinator.cloudSQLProxy.validationIssues.count >= 3) + #expect(coordinator.socksProxy.validationIssues.count >= 3) + } +} diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index c921ead1a..3410cb98b 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -9,32 +9,32 @@ TablePro connects to 22 databases through its plugin system. This page covers cr ## Supported Databases -| Database | Default port | SSH tunnel | SSL/TLS | Cloudflare Tunnel | Cloud SQL Proxy | -|----------|-------------|-----------|---------|-------------------|-----------------| -| [MySQL](/databases/mysql) | 3306 | Yes | Yes | Yes | Yes | -| [MariaDB](/databases/mariadb) | 3306 | Yes | Yes | Yes | No | -| [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | -| [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | -| [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | -| [Microsoft SQL Server](/databases/mssql) | 1433 | Yes | Yes | Yes | Yes | -| [Oracle](/databases/oracle) | 1521 | Yes | Yes | Yes | No | -| [ClickHouse](/databases/clickhouse) | 8123 | Yes | Yes | Yes | No | -| [MongoDB](/databases/mongodb) | 27017 | Yes | Yes | Yes | No | -| [Redis](/databases/redis) | 6379 | Yes | Yes | Yes | No | -| [Cassandra / ScyllaDB](/databases/cassandra) | 9042 | Yes | Yes | Yes | No | -| [etcd](/databases/etcd) | 2379 | Yes | Yes | Yes | No | -| [SurrealDB](/databases/surrealdb) | 8000 | Yes | Yes | Yes | No | -| [Elasticsearch](/databases/elasticsearch) | 9200 | No | Yes | No | No | -| [Snowflake](/databases/snowflake) | 443 | No | No | No | No | -| [SQLite](/databases/sqlite) | File | No | No | No | No | -| [DuckDB](/databases/duckdb) | File | No | No | No | No | -| [Beancount](/databases/beancount) | File | No | No | No | No | -| [DynamoDB](/databases/dynamodb) | AWS API | No | No | No | No | -| [BigQuery](/databases/bigquery) | Cloud API | No | No | No | No | -| [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | -| [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | - -Transport details: [SSH Tunneling](/databases/ssh-tunneling), [SSL/TLS](/features/ssl), [Cloudflare Tunnel](/databases/cloudflare-tunnel), [Cloud SQL Auth Proxy](/databases/cloud-sql-proxy). +| Database | Default port | SSH tunnel | SSL/TLS | Cloudflare Tunnel | Cloud SQL Proxy | SOCKS Proxy | +|----------|-------------|-----------|---------|-------------------|-----------------|-------------| +| [MySQL](/databases/mysql) | 3306 | Yes | Yes | Yes | Yes | Yes | +| [MariaDB](/databases/mariadb) | 3306 | Yes | Yes | Yes | No | Yes | +| [PostgreSQL](/databases/postgresql) | 5432 | Yes | Yes | Yes | Yes | Yes | +| [Amazon Redshift](/databases/redshift) | 5439 | Yes | Yes | Yes | No | Yes | +| [CockroachDB](/databases/cockroachdb) | 26257 | Yes | Yes | Yes | No | Yes | +| [Microsoft SQL Server](/databases/mssql) | 1433 | Yes | Yes | Yes | Yes | Yes | +| [Oracle](/databases/oracle) | 1521 | Yes | Yes | Yes | No | Yes | +| [ClickHouse](/databases/clickhouse) | 8123 | Yes | Yes | Yes | No | Yes | +| [MongoDB](/databases/mongodb) | 27017 | Yes | Yes | Yes | No | Yes | +| [Redis](/databases/redis) | 6379 | Yes | Yes | Yes | No | Yes | +| [Cassandra / ScyllaDB](/databases/cassandra) | 9042 | Yes | Yes | Yes | No | Yes | +| [etcd](/databases/etcd) | 2379 | Yes | Yes | Yes | No | Yes | +| [SurrealDB](/databases/surrealdb) | 8000 | Yes | Yes | Yes | No | Yes | +| [Elasticsearch](/databases/elasticsearch) | 9200 | No | Yes | No | No | No | +| [Snowflake](/databases/snowflake) | 443 | No | No | No | No | No | +| [SQLite](/databases/sqlite) | File | No | No | No | No | No | +| [DuckDB](/databases/duckdb) | File | No | No | No | No | No | +| [Beancount](/databases/beancount) | File | No | No | No | No | No | +| [DynamoDB](/databases/dynamodb) | AWS API | No | No | No | No | No | +| [BigQuery](/databases/bigquery) | Cloud API | No | No | No | No | No | +| [Cloudflare D1](/databases/cloudflare-d1) | Cloud API | No | No | No | No | No | +| [libSQL / Turso](/databases/libsql) | URL | No | No | No | No | No | + +Transport details: [SSH Tunneling](/databases/ssh-tunneling), [SSL/TLS](/features/ssl), [Cloudflare Tunnel](/databases/cloudflare-tunnel), [Cloud SQL Auth Proxy](/databases/cloud-sql-proxy), [SOCKS Proxy](/databases/socks-proxy). ## Creating a Connection @@ -91,7 +91,7 @@ What happens on open: ## Connection Form -The form is a sidebar with up to eight panes. Tunnel and SSL panes appear only for drivers that support them (see the matrix above). A warning triangle on a sidebar item marks missing required fields. +The form is a sidebar with up to nine panes. Tunnel and SSL panes appear only for drivers that support them (see the matrix above). A warning triangle on a sidebar item marks missing required fields. | Pane | Contents | |------|----------| @@ -99,6 +99,7 @@ The form is a sidebar with up to eight panes. Tunnel and SSL panes appear only f | **SSH Tunnel** | Reach databases behind a bastion host. See [SSH Tunneling](/databases/ssh-tunneling) | | **Cloudflare Tunnel** | Connect through `cloudflared`. See [Cloudflare Tunnel](/databases/cloudflare-tunnel) | | **Cloud SQL Auth Proxy** | Google Cloud SQL proxy for MySQL, PostgreSQL, SQL Server. See [Cloud SQL Auth Proxy](/databases/cloud-sql-proxy) | +| **SOCKS Proxy** | Route the connection through a SOCKS5 proxy. See [SOCKS Proxy](/databases/socks-proxy) | | **SSL/TLS** | Encryption mode and certificates. See [SSL/TLS](/features/ssl) | | **Customization** | Color, tags, group, Safe Mode | | **Advanced** | Startup commands, pre-connect script, external access, plugin-specific fields | diff --git a/docs/databases/socks-proxy.mdx b/docs/databases/socks-proxy.mdx new file mode 100644 index 000000000..381f85274 --- /dev/null +++ b/docs/databases/socks-proxy.mdx @@ -0,0 +1,67 @@ +--- +title: SOCKS Proxy +description: Route a database connection through a SOCKS5 proxy +--- + +# SOCKS Proxy + +SOCKS Proxy routes your database connection through a SOCKS5 proxy server. Use it when the database is only reachable through a corporate proxy, a bastion running `ssh -D`, or another SOCKS5 endpoint. + +## How it works + +```mermaid +flowchart LR + subgraph mac ["Your Mac"] + Driver["Database driver
127.0.0.1:auto"] + Relay["TablePro relay"] + end + + subgraph proxy ["SOCKS5 Proxy"] + SOCKS["proxy:1080"] + end + + subgraph db ["Database Server"] + Database["PostgreSQL
MySQL
db:5432"] + end + + Driver -->|"loopback"| Relay -->|"SOCKS5"| SOCKS -->|"TCP"| Database +``` + +TablePro opens a loopback listener on a free port and relays every connection through the proxy to the database, then points the driver at the local port. Unlike Cloudflare Tunnel and Cloud SQL Auth Proxy, there is no external binary to install: TablePro's own networking stack (Network.framework) speaks SOCKS5 directly. + +The database hostname is sent to the proxy and resolved there, not on your Mac. Hostnames that only resolve inside the private network behind the proxy work, and no DNS query for the database host leaves your machine. + +## Setting up + +The SOCKS Proxy pane appears for the same databases that support SSH tunneling. SQLite and cloud-managed databases like BigQuery, DynamoDB, and Snowflake don't show it. + +Open the connection form, switch to the **SOCKS Proxy** pane, toggle **Enable SOCKS Proxy** on, enter the proxy **host** and **port**, then go back to **General** and click **Test Connection**. + +A connection uses one connection method at a time. If the SSH Tunnel, Cloudflare Tunnel, or Cloud SQL Auth Proxy is enabled, the pane offers to turn it off. + +## Options + +| Field | Description | Default | +|-------|-------------|---------| +| **Host** | The SOCKS5 proxy server address. | - | +| **Port** | The proxy port. | 1080 | +| **Username** | Optional. Set it when the proxy requires username and password authentication. | - | +| **Password** | Optional. Stored in the macOS Keychain. | - | + + +An SSH dynamic port forward is a SOCKS5 proxy. Run `ssh -D 1080 user@bastion` in a terminal, then set the proxy host to `127.0.0.1` and the port to `1080`. + + +## Troubleshooting + +### Timed out connecting through the proxy + +TablePro waits 15 seconds for the path through the proxy to come up. A timeout means the proxy did not answer, the credentials were rejected, or the proxy could not reach the database. Check the proxy host and port, verify the username and password, and confirm the database host and port are reachable from the proxy's network. + +### Local Network permission prompt + +On macOS 15 and later, connecting to a proxy on your local network (a `192.168.x.x` address, for example) triggers the one-time Local Network permission alert. Allow it, or the proxy is unreachable. Loopback proxies like `ssh -D` on `127.0.0.1` don't trigger the prompt. + +### The database rejects the connection + +The proxy path is working but the database refused the connection. The driver's own error is shown, the same as connecting directly. Check credentials, SSL settings, and that the database allows connections from the proxy's address. diff --git a/docs/docs.json b/docs/docs.json index 0501f792d..51b6cede3 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -44,6 +44,7 @@ "databases/ssh-tunneling", "databases/cloudflare-tunnel", "databases/cloud-sql-proxy", + "databases/socks-proxy", "databases/aws-iam", { "group": "SQL",