From 3307bd90e401448fdab2b91286804f4bf8e94510 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 19 Oct 2026 10:40:04 +0100 Subject: [PATCH] new tests --- DBSyn/DatabaseModel.swift | 32 ++++++++++++ DBSyn/MainView.swift | 89 +++++++++++++++------------------ DBSyn/MySQLProtocol.swift | 49 +++++++++++++++++- docs/PHASE74-SSH-CONNECTIONS.md | 10 ++++ 4 files changed, 128 insertions(+), 52 deletions(-) create mode 100644 docs/PHASE74-SSH-CONNECTIONS.md diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index c4565ad..c09eaed 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -9,6 +9,32 @@ struct ConnectionProfile: Identifiable, Codable, Hashable { var port = 3306 var username = "root" var database = "" + // Optional SSH transport. Existing saved profiles decode with these defaults. + var useSSHTunnel = false + var sshHost = "" + var sshPort = 22 + var sshUser = "" + var sshIdentityFile = "" + + enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, sshHost, sshPort, sshUser, sshIdentityFile } + init(id: UUID = UUID(), name: String = "Local MySQL", host: String = "127.0.0.1", port: Int = 3306, username: String = "root", database: String = "", useSSHTunnel: Bool = false, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") { + self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database + self.useSSHTunnel = useSSHTunnel; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + host = try c.decode(String.self, forKey: .host) + port = try c.decode(Int.self, forKey: .port) + username = try c.decode(String.self, forKey: .username) + database = try c.decode(String.self, forKey: .database) + useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false + sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? "" + sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22 + sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? "" + sshIdentityFile = try c.decodeIfPresent(String.self, forKey: .sshIdentityFile) ?? "" + } } struct QueryResult { var columns: [String] = [] @@ -651,7 +677,10 @@ final class DatabaseModel: ObservableObject { func selectConnection(_ id: UUID) { guard let profile = profiles.first(where: { $0.id == id }) else { return } + guard selectedID != id else { return } saveActiveQuery() + if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) } + result = QueryResult() selectedID = id UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID") selectedDatabase = profile.database @@ -673,10 +702,12 @@ final class DatabaseModel: ObservableObject { if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile } else { profiles.append(profile) } PasswordStore.save(password, id: profile.id) + NativeMySQLRunner.disconnect(profileID: profile.id) selectedID = profile.id selectedDatabase = profile.database UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID") persist() + loadDatabases() } private func persist() { if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) } @@ -854,6 +885,7 @@ final class DatabaseModel: ObservableObject { introspect(database: usedSchema) } } + guard selectedID == profile.id else { isRunning = false; return } if listingDatabases { databases = lastResult.rows.compactMap { $0.first } status = "Connected to \(profile.name)" diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index f127772..996af79 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -2483,81 +2483,70 @@ private struct WorkbenchExplainView: View { struct ConnectionEditor: View { @Binding var profile: ConnectionProfile @Binding var password: String - let save: () -> Void - @Environment(\.dismiss) private var dismiss @State private var testMessage = "" + @State private var testing = false var body: some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 18) { HStack { - Text("Connection Settings").font(.title2.bold()) + Label("Connection Settings", systemImage: "server.rack").font(.title2.bold()) Spacer() Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.borderless) - .help("Close") + .buttonStyle(.plain).help("Close") } - Form { - TextField( - "Connection Name", - text: $profile.name - ) - - TextField( - "Hostname", - text: $profile.host - ) - - TextField("Port", value: $profile.port, format: .number.grouping(.never)) - - TextField( - "Username", - text: $profile.username - ) - - SecureField( - "Password (macOS Keychain)", - text: $password - ) - - TextField("Default Schema", text: $profile.database) - Text("Remote MySQL requires a trusted SSH tunnel until native TLS support is available.") - .font(.caption).foregroundStyle(.secondary) + Section("MySQL") { + TextField("Connection Name", text: $profile.name) + TextField("Hostname", text: $profile.host) + TextField("Port", value: $profile.port, format: .number.grouping(.never)) + TextField("Username", text: $profile.username) + SecureField("Password (Keychain)", text: $password) + TextField("Default Schema", text: $profile.database) + } + Section("Secure Transport") { + Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel) + if profile.useSSHTunnel { + TextField("SSH Host", text: $profile.sshHost) + TextField("SSH Port", value: $profile.sshPort, format: .number.grouping(.never)) + TextField("SSH Username", text: $profile.sshUser) + TextField("SSH Private Key (optional)", text: $profile.sshIdentityFile) + Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("Direct remote MySQL requires TLS, which DBSyn does not yet implement. Enable SSH for remote servers.") + .font(.caption).foregroundStyle(.secondary) + } + } } - + .formStyle(.grouped) if !testMessage.isEmpty { - Text(testMessage).font(.caption).foregroundStyle(.secondary) + Text(testMessage).font(.callout).foregroundStyle(testMessage.hasPrefix("Connected") ? .green : .red) .textSelection(.enabled) } HStack { - Button("Test Connection") { + Button(testing ? "Testing…" : "Test Connection") { + testing = true + testMessage = "" let candidate = profile let secret = password Task { + defer { testing = false } do { - let result = try await NativeMySQLRunner.run( - profile: candidate, database: candidate.database, - password: secret, sql: "SELECT VERSION()") - testMessage = "Connection successful: " + (result.rows.first?.first ?? "MySQL") + let result = try await NativeMySQLRunner.run(profile: candidate, database: candidate.database, password: secret, sql: "SELECT VERSION()") + testMessage = "Connected: " + (result.rows.first?.first ?? "MySQL") } catch { testMessage = error.localizedDescription } + NativeMySQLRunner.disconnect(profileID: candidate.id) } - } + }.disabled(testing) Spacer() - - Button("Cancel") { - dismiss() - } - - Button("Save") { - save() - } - .buttonStyle(.borderedProminent) + Button("Cancel") { dismiss() } + Button("Save") { save() }.buttonStyle(.borderedProminent) } } .padding(24) - .frame(width: 520) + .frame(minWidth: 720, idealWidth: 780, minHeight: 660) } } diff --git a/DBSyn/MySQLProtocol.swift b/DBSyn/MySQLProtocol.swift index ab7fdc9..d7a05a5 100644 --- a/DBSyn/MySQLProtocol.swift +++ b/DBSyn/MySQLProtocol.swift @@ -10,6 +10,49 @@ struct MySQLWireError: LocalizedError { var errorDescription: String? { message } } +// SSH transport uses the system OpenSSH client and key-based authentication. +// No remote MySQL password is passed to ssh. SSH host-key verification remains enabled. +private final class DBSynSSHTunnels { + static let shared = DBSynSSHTunnels() + private let lock = NSLock() + private var running: [UUID: (Process, ConnectionProfile, Int)] = [:] + + func endpoint(for profile: ConnectionProfile) throws -> (String, Int) { + guard profile.useSSHTunnel else { return (profile.host, profile.port) } + lock.lock(); defer { lock.unlock() } + if let current = running[profile.id] { + if current.0.isRunning && current.1 == profile { return ("127.0.0.1", current.2) } + if current.0.isRunning { current.0.terminate() } + running.removeValue(forKey: profile.id) + } + guard !profile.sshHost.isEmpty, !profile.sshUser.isEmpty else { + throw MySQLWireError(message: "Configure SSH hostname and SSH username in Connection Settings") + } + // A stable local port per connection; ExitOnForwardFailure rejects conflicts. + let bytes = Array(profile.id.uuidString.utf8) + let checksum = bytes.reduce(0) { ($0 &* 31 &+ Int($1)) % 10000 } + let localPort = 45000 + checksum + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + var args = ["-N", "-T", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(profile.sshPort)] + if !profile.sshIdentityFile.isEmpty { args += ["-i", (profile.sshIdentityFile as NSString).expandingTildeInPath] } + args += ["-L", "127.0.0.1:\(localPort):\(profile.host):\(profile.port)", "\(profile.sshUser)@\(profile.sshHost)"] + process.arguments = args + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { try process.run() } catch { throw MySQLWireError(message: "Cannot launch SSH: \(error.localizedDescription)") } + Thread.sleep(forTimeInterval: 0.6) + guard process.isRunning else { throw MySQLWireError(message: "SSH tunnel failed. Check SSH key, known_hosts, target and port \(localPort). Try ssh manually for details.") } + running[profile.id] = (process, profile, localPort) + return ("127.0.0.1", localPort) + } + func stop(_ id: UUID) { + lock.lock(); defer { lock.unlock() } + if let existing = running.removeValue(forKey: id), existing.0.isRunning { existing.0.terminate() } + } +} + private struct ByteCursor { let bytes: [UInt8] var offset = 0 @@ -371,8 +414,9 @@ private final class PersistentMySQLSessions { } if connections[profile.id] == nil { - let connection = try MySQLWireConnection(host: profile.host, port: profile.port) - connection.markHost(profile.host) + let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile) + let connection = try MySQLWireConnection(host: endpoint.0, port: endpoint.1) + connection.markHost(endpoint.0) try connection.authenticate(user: profile.username, password: password, database: database) connections[profile.id] = Entry(profile: profile, @@ -415,6 +459,7 @@ private final class PersistentMySQLSessions { func disconnect(profileID: UUID) { queue.sync { _ = connections.removeValue(forKey: profileID) } + DBSynSSHTunnels.shared.stop(profileID) } } diff --git a/docs/PHASE74-SSH-CONNECTIONS.md b/docs/PHASE74-SSH-CONNECTIONS.md new file mode 100644 index 0000000..256c299 --- /dev/null +++ b/docs/PHASE74-SSH-CONNECTIONS.md @@ -0,0 +1,10 @@ +# DBSyn Phase 74 — Connection repair + +- Existing connection profiles decode without losing saved credentials. +- Selecting another connection closes the previous MySQL session, clears stale results, and reloads schemas. +- Editing and saving a profile disconnects its existing session and reloads databases. +- Connection editor now includes integrated SSH forwarding using system OpenSSH and verified known_hosts. +- Enable SSH and enter SSH host, SSH user, and optionally an identity file. MySQL host/port refer to the endpoint accessible *from the SSH server* (usually 127.0.0.1:3306). +- SSH is key-based / agent-based (BatchMode=yes); password-based SSH authentication is not implemented. +- Native direct MySQL TLS remains unimplemented. Direct remote connections remain blocked intentionally. +- Tests and Xcode compilation must be run on macOS; Linux Swift syntax parse is not a macOS build.