new tests
This commit is contained in:
@@ -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)"
|
||||
|
||||
+39
-50
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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