new tests
This commit is contained in:
@@ -9,6 +9,32 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
|
|||||||
var port = 3306
|
var port = 3306
|
||||||
var username = "root"
|
var username = "root"
|
||||||
var database = ""
|
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 {
|
struct QueryResult {
|
||||||
var columns: [String] = []
|
var columns: [String] = []
|
||||||
@@ -651,7 +677,10 @@ final class DatabaseModel: ObservableObject {
|
|||||||
|
|
||||||
func selectConnection(_ id: UUID) {
|
func selectConnection(_ id: UUID) {
|
||||||
guard let profile = profiles.first(where: { $0.id == id }) else { return }
|
guard let profile = profiles.first(where: { $0.id == id }) else { return }
|
||||||
|
guard selectedID != id else { return }
|
||||||
saveActiveQuery()
|
saveActiveQuery()
|
||||||
|
if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) }
|
||||||
|
result = QueryResult()
|
||||||
selectedID = id
|
selectedID = id
|
||||||
UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
||||||
selectedDatabase = profile.database
|
selectedDatabase = profile.database
|
||||||
@@ -673,10 +702,12 @@ final class DatabaseModel: ObservableObject {
|
|||||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||||
else { profiles.append(profile) }
|
else { profiles.append(profile) }
|
||||||
PasswordStore.save(password, id: profile.id)
|
PasswordStore.save(password, id: profile.id)
|
||||||
|
NativeMySQLRunner.disconnect(profileID: profile.id)
|
||||||
selectedID = profile.id
|
selectedID = profile.id
|
||||||
selectedDatabase = profile.database
|
selectedDatabase = profile.database
|
||||||
UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID")
|
||||||
persist()
|
persist()
|
||||||
|
loadDatabases()
|
||||||
}
|
}
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||||
@@ -854,6 +885,7 @@ final class DatabaseModel: ObservableObject {
|
|||||||
introspect(database: usedSchema)
|
introspect(database: usedSchema)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
guard selectedID == profile.id else { isRunning = false; return }
|
||||||
if listingDatabases {
|
if listingDatabases {
|
||||||
databases = lastResult.rows.compactMap { $0.first }
|
databases = lastResult.rows.compactMap { $0.first }
|
||||||
status = "Connected to \(profile.name)"
|
status = "Connected to \(profile.name)"
|
||||||
|
|||||||
+39
-50
@@ -2483,81 +2483,70 @@ private struct WorkbenchExplainView: View {
|
|||||||
struct ConnectionEditor: View {
|
struct ConnectionEditor: View {
|
||||||
@Binding var profile: ConnectionProfile
|
@Binding var profile: ConnectionProfile
|
||||||
@Binding var password: String
|
@Binding var password: String
|
||||||
|
|
||||||
let save: () -> Void
|
let save: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var testMessage = ""
|
@State private var testMessage = ""
|
||||||
|
@State private var testing = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Connection Settings").font(.title2.bold())
|
Label("Connection Settings", systemImage: "server.rack").font(.title2.bold())
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.plain).help("Close")
|
||||||
.help("Close")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
TextField(
|
Section("MySQL") {
|
||||||
"Connection Name",
|
TextField("Connection Name", text: $profile.name)
|
||||||
text: $profile.name
|
TextField("Hostname", text: $profile.host)
|
||||||
)
|
TextField("Port", value: $profile.port, format: .number.grouping(.never))
|
||||||
|
TextField("Username", text: $profile.username)
|
||||||
TextField(
|
SecureField("Password (Keychain)", text: $password)
|
||||||
"Hostname",
|
TextField("Default Schema", text: $profile.database)
|
||||||
text: $profile.host
|
}
|
||||||
)
|
Section("Secure Transport") {
|
||||||
|
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
|
||||||
TextField("Port", value: $profile.port, format: .number.grouping(.never))
|
if profile.useSSHTunnel {
|
||||||
|
TextField("SSH Host", text: $profile.sshHost)
|
||||||
TextField(
|
TextField("SSH Port", value: $profile.sshPort, format: .number.grouping(.never))
|
||||||
"Username",
|
TextField("SSH Username", text: $profile.sshUser)
|
||||||
text: $profile.username
|
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)
|
||||||
SecureField(
|
} else {
|
||||||
"Password (macOS Keychain)",
|
Text("Direct remote MySQL requires TLS, which DBSyn does not yet implement. Enable SSH for remote servers.")
|
||||||
text: $password
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
)
|
}
|
||||||
|
}
|
||||||
TextField("Default Schema", text: $profile.database)
|
|
||||||
Text("Remote MySQL requires a trusted SSH tunnel until native TLS support is available.")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
if !testMessage.isEmpty {
|
if !testMessage.isEmpty {
|
||||||
Text(testMessage).font(.caption).foregroundStyle(.secondary)
|
Text(testMessage).font(.callout).foregroundStyle(testMessage.hasPrefix("Connected") ? .green : .red)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Button("Test Connection") {
|
Button(testing ? "Testing…" : "Test Connection") {
|
||||||
|
testing = true
|
||||||
|
testMessage = ""
|
||||||
let candidate = profile
|
let candidate = profile
|
||||||
let secret = password
|
let secret = password
|
||||||
Task {
|
Task {
|
||||||
|
defer { testing = false }
|
||||||
do {
|
do {
|
||||||
let result = try await NativeMySQLRunner.run(
|
let result = try await NativeMySQLRunner.run(profile: candidate, database: candidate.database, password: secret, sql: "SELECT VERSION()")
|
||||||
profile: candidate, database: candidate.database,
|
testMessage = "Connected: " + (result.rows.first?.first ?? "MySQL")
|
||||||
password: secret, sql: "SELECT VERSION()")
|
|
||||||
testMessage = "Connection successful: " + (result.rows.first?.first ?? "MySQL")
|
|
||||||
} catch { testMessage = error.localizedDescription }
|
} catch { testMessage = error.localizedDescription }
|
||||||
|
NativeMySQLRunner.disconnect(profileID: candidate.id)
|
||||||
}
|
}
|
||||||
}
|
}.disabled(testing)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
Button("Cancel") {
|
Button("Save") { save() }.buttonStyle(.borderedProminent)
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Save") {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
.frame(width: 520)
|
.frame(minWidth: 720, idealWidth: 780, minHeight: 660)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,49 @@ struct MySQLWireError: LocalizedError {
|
|||||||
var errorDescription: String? { message }
|
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 {
|
private struct ByteCursor {
|
||||||
let bytes: [UInt8]
|
let bytes: [UInt8]
|
||||||
var offset = 0
|
var offset = 0
|
||||||
@@ -371,8 +414,9 @@ private final class PersistentMySQLSessions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if connections[profile.id] == nil {
|
if connections[profile.id] == nil {
|
||||||
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
|
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
|
||||||
connection.markHost(profile.host)
|
let connection = try MySQLWireConnection(host: endpoint.0, port: endpoint.1)
|
||||||
|
connection.markHost(endpoint.0)
|
||||||
try connection.authenticate(user: profile.username,
|
try connection.authenticate(user: profile.username,
|
||||||
password: password, database: database)
|
password: password, database: database)
|
||||||
connections[profile.id] = Entry(profile: profile,
|
connections[profile.id] = Entry(profile: profile,
|
||||||
@@ -415,6 +459,7 @@ private final class PersistentMySQLSessions {
|
|||||||
|
|
||||||
func disconnect(profileID: UUID) {
|
func disconnect(profileID: UUID) {
|
||||||
queue.sync { _ = connections.removeValue(forKey: profileID) }
|
queue.sync { _ = connections.removeValue(forKey: profileID) }
|
||||||
|
DBSynSSHTunnels.shared.stop(profileID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user