From c4cd135794be245b6402893fbea45782cb9b2ae1 Mon Sep 17 00:00:00 2001 From: Bare7a Date: Wed, 5 Aug 2026 16:18:43 +0300 Subject: [PATCH] feat(XS-50): Adds SSH support --- README.md | 25 +- docs/index.html | 6 + frontend/bindings/xensql/internal/app/app.ts | 83 +-- .../bindings/xensql/internal/app/index.ts | 3 +- .../bindings/xensql/internal/app/models.ts | 34 ++ .../xensql/internal/database/index.ts | 2 + .../xensql/internal/database/models.ts | 100 +++- .../features/connections/ConnectionDialog.tsx | 162 +++++- frontend/src/i18n/locales.test.ts | 45 ++ frontend/src/i18n/locales/bg.json | 24 +- frontend/src/i18n/locales/de.json | 24 +- frontend/src/i18n/locales/en.json | 24 +- frontend/src/shared/hooks/usePathDefaults.ts | 21 + frontend/src/shared/lib/api.ts | 6 + frontend/src/shared/lib/pathDefaults.ts | 18 + frontend/src/styles/forms.css | 9 + frontend/src/types/index.ts | 18 + go.mod | 1 + go.sum | 4 + internal/app/app_files.go | 18 + internal/app/app_paths.go | 30 ++ internal/app/app_paths_test.go | 53 ++ internal/app/e2e_ssh_test.go | 284 ++++++++++ internal/database/config.go | 60 ++- internal/database/config_ssh_test.go | 166 ++++++ internal/database/mysql/driver.go | 32 +- internal/database/postgres/driver.go | 23 +- internal/database/session_base.go | 13 +- internal/database/ssh.go | 26 + internal/database/sshtunnel/tunnel.go | 305 +++++++++++ internal/database/sshtunnel/tunnel_test.go | 493 ++++++++++++++++++ internal/database/types.go | 26 + 32 files changed, 2061 insertions(+), 77 deletions(-) create mode 100644 frontend/src/i18n/locales.test.ts create mode 100644 frontend/src/shared/hooks/usePathDefaults.ts create mode 100644 frontend/src/shared/lib/pathDefaults.ts create mode 100644 internal/app/app_paths.go create mode 100644 internal/app/app_paths_test.go create mode 100644 internal/app/e2e_ssh_test.go create mode 100644 internal/database/config_ssh_test.go create mode 100644 internal/database/ssh.go create mode 100644 internal/database/sshtunnel/tunnel.go create mode 100644 internal/database/sshtunnel/tunnel_test.go diff --git a/README.md b/README.md index b755ef5..3fb30bd 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Work with **SQLite**, **PostgreSQL** and **MySQL / MariaDB** in a single fast de ### 👉 Everything you need. Nothing you don’t. - Query faster with smart, schema-aware autocomplete +- Reach databases behind a bastion over an SSH tunnel - Stream results - rows arrive as the driver yields them - Run multi-statement scripts and get a result tab per output - Explore schemas instantly @@ -127,12 +128,12 @@ XenSQL focuses on one thing: ## 🗄️ Supported Databases -| Database | Read & write | Read-only mode | Secure transport | -|----------|:------------:|:--------------:|:-----------------| -| **PostgreSQL** | ✅ | ✅ | SSL - `disable` / `require` / `verify-full` | -| **MySQL** | ✅ | ✅ | TLS | -| **MariaDB** | ✅ | ✅ | TLS | -| **SQLite** | ✅ | ✅ | local file | +| Database | Read & write | Read-only mode | Secure transport | SSH tunnel | +|----------|:------------:|:--------------:|:-----------------|:----------:| +| **PostgreSQL** | ✅ | ✅ | SSL - `disable` / `require` / `verify-full` | ✅ | +| **MySQL** | ✅ | ✅ | TLS | ✅ | +| **MariaDB** | ✅ | ✅ | TLS | ✅ | +| **SQLite** | ✅ | ✅ | local file | n/a | --- @@ -143,8 +144,20 @@ XenSQL focuses on one thing: - Per-connection **tab colors** - **Read-only mode** with defense-in-depth - blocked at both the Wails layer and inside each driver - PostgreSQL SSL (`disable` / `require` / `verify-full`) and MySQL TLS +- **SSH tunnel** to reach databases behind a bastion (see below) - SQLite file picker workflow +### 🔐 SSH Tunnel + +Connect to a database that only its bastion can reach - no `ssh -L` in a side terminal. + +- Auth by **private key** (with passphrase), **password**, or a running **ssh-agent** +- Host keys verified against `~/.ssh/known_hosts` by default, with an override for a custom file +- Unknown bastion? The error tells you the exact `ssh-keyscan` line to run - or tick **Skip host key check** +- Keepalives hold the tunnel open under a long, idle session +- The tunnel carries the driver's own traffic, so the database host stays what the bastion resolves + (usually `localhost`) and `sslmode=verify-full` still checks the real certificate + --- ## 🧠 SQL Editor diff --git a/docs/index.html b/docs/index.html index 5162c22..9d45276 100644 --- a/docs/index.html +++ b/docs/index.html @@ -130,6 +130,7 @@

Supported databases

@@ -138,6 +139,7 @@

Supported databases

@@ -194,6 +196,10 @@

📤 One-click export

📚 Query library & history

Save, rename, filter and reuse queries with dirty-state tracking, plus per-connection history with success/error status and duration.

+
+

🔐 SSH tunnel

+

Reach a database only its bastion can see - no ssh -L in a side terminal. Private key, password or ssh-agent auth, with host keys checked against known_hosts.

+

🔌 Connection manager

Create, test and organize connections into drag-and-drop folders, with per-connection tab colors and a defense-in-depth read-only mode.

diff --git a/frontend/bindings/xensql/internal/app/app.ts b/frontend/bindings/xensql/internal/app/app.ts index e1dcb13..58447b2 100644 --- a/frontend/bindings/xensql/internal/app/app.ts +++ b/frontend/bindings/xensql/internal/app/app.ts @@ -134,21 +134,27 @@ export function GetEditorSession(): $CancellablePromise }); } +export function GetPathDefaults(): $CancellablePromise<$models.PathDefaults> { + return $Call.ByID(230484794).then(($result: any) => { + return $$createType5($result); + }); +} + export function GetPendingFile(): $CancellablePromise<{ [_ in string]?: string }> { return $Call.ByID(109090272).then(($result: any) => { - return $$createType5($result); + return $$createType6($result); }); } export function GetQueryHistory(connectionID: string, limit: number): $CancellablePromise { return $Call.ByID(4033942967, connectionID, limit).then(($result: any) => { - return $$createType7($result); + return $$createType8($result); }); } export function GetSettings(): $CancellablePromise<{ [_ in string]?: string }> { return $Call.ByID(222085978).then(($result: any) => { - return $$createType5($result); + return $$createType6($result); }); } @@ -158,7 +164,7 @@ export function InitStores(configDir: string): $CancellablePromise { export function InsertRow(connectionID: string, schema: string, table: string, values: { [_ in string]?: any }): $CancellablePromise<{ [_ in string]?: any }> { return $Call.ByID(1655633052, connectionID, schema, table, values).then(($result: any) => { - return $$createType8($result); + return $$createType9($result); }); } @@ -172,37 +178,37 @@ export function IsDesktopMode(): $CancellablePromise { export function ListColumns(connectionID: string, schema: string, table: string): $CancellablePromise { return $Call.ByID(3118816600, connectionID, schema, table).then(($result: any) => { - return $$createType10($result); + return $$createType11($result); }); } export function ListConnections(): $CancellablePromise { return $Call.ByID(1654481338).then(($result: any) => { - return $$createType12($result); + return $$createType13($result); }); } export function ListFolders(): $CancellablePromise { return $Call.ByID(1373072582).then(($result: any) => { - return $$createType14($result); + return $$createType15($result); }); } export function ListSavedQueries(connectionID: string): $CancellablePromise { return $Call.ByID(2254370512, connectionID).then(($result: any) => { - return $$createType16($result); + return $$createType17($result); }); } export function ListSchemas(connectionID: string): $CancellablePromise { return $Call.ByID(2969331507, connectionID).then(($result: any) => { - return $$createType18($result); + return $$createType19($result); }); } export function ListTables(connectionID: string, schema: string): $CancellablePromise { return $Call.ByID(773846824, connectionID, schema).then(($result: any) => { - return $$createType20($result); + return $$createType21($result); }); } @@ -212,7 +218,7 @@ export function ListTables(connectionID: string, schema: string): $CancellablePr */ export function LoadSchemaData(connectionID: string): $CancellablePromise { return $Call.ByID(4233994986, connectionID).then(($result: any) => { - return $$createType21($result); + return $$createType22($result); }); } @@ -220,10 +226,18 @@ export function PickExportSavePath(ext: string): $CancellablePromise { return $Call.ByID(1154457340, ext); } +export function PickKnownHostsFile(): $CancellablePromise { + return $Call.ByID(155068216); +} + export function PickSQLiteFile(): $CancellablePromise { return $Call.ByID(2769801138); } +export function PickSSHKeyFile(): $CancellablePromise { + return $Call.ByID(4136702487); +} + export function QueryTableStream(connectionID: string, tabID: string, req: database$0.TableDataRequest): $CancellablePromise { return $Call.ByID(3607474293, connectionID, tabID, req); } @@ -241,7 +255,7 @@ export function RollbackTransaction(tabID: string): $CancellablePromise { export function SaveConnection(cfg: database$0.ConnectionConfig): $CancellablePromise { return $Call.ByID(571429232, cfg).then(($result: any) => { - return $$createType11($result); + return $$createType12($result); }); } @@ -251,13 +265,13 @@ export function SaveEditorSession(session: storage$0.EditorSession): $Cancellabl export function SaveFolder(f: storage$0.ConnectionFolder): $CancellablePromise { return $Call.ByID(1026390748, f).then(($result: any) => { - return $$createType13($result); + return $$createType14($result); }); } export function SaveSavedQuery(q: database$0.SavedQuery): $CancellablePromise { return $Call.ByID(1936361457, q).then(($result: any) => { - return $$createType15($result); + return $$createType16($result); }); } @@ -293,7 +307,7 @@ export function SetWindowStateFlush(flush: any): $CancellablePromise { export function SettingsStore(): $CancellablePromise { return $Call.ByID(2329735545).then(($result: any) => { - return $$createType23($result); + return $$createType24($result); }); } @@ -318,22 +332,23 @@ const $$createType1 = $Create.Nullable($$createType0); const $$createType2 = $models.AppInfo.createFrom; const $$createType3 = database$0.ConnectionStatus.createFrom; const $$createType4 = storage$0.EditorSession.createFrom; -const $$createType5 = $Create.Map($Create.Any, $Create.Any); -const $$createType6 = database$0.HistoryEntry.createFrom; -const $$createType7 = $Create.Array($$createType6); -const $$createType8 = $Create.Map($Create.Any, $Create.Any); -const $$createType9 = database$0.ColumnInfo.createFrom; -const $$createType10 = $Create.Array($$createType9); -const $$createType11 = database$0.ConnectionConfig.createFrom; -const $$createType12 = $Create.Array($$createType11); -const $$createType13 = storage$0.ConnectionFolder.createFrom; -const $$createType14 = $Create.Array($$createType13); -const $$createType15 = database$0.SavedQuery.createFrom; -const $$createType16 = $Create.Array($$createType15); -const $$createType17 = database$0.SchemaInfo.createFrom; -const $$createType18 = $Create.Array($$createType17); -const $$createType19 = database$0.TableInfo.createFrom; -const $$createType20 = $Create.Array($$createType19); -const $$createType21 = database$0.SchemaBundle.createFrom; -const $$createType22 = storage$0.SettingsStore.createFrom; -const $$createType23 = $Create.Nullable($$createType22); +const $$createType5 = $models.PathDefaults.createFrom; +const $$createType6 = $Create.Map($Create.Any, $Create.Any); +const $$createType7 = database$0.HistoryEntry.createFrom; +const $$createType8 = $Create.Array($$createType7); +const $$createType9 = $Create.Map($Create.Any, $Create.Any); +const $$createType10 = database$0.ColumnInfo.createFrom; +const $$createType11 = $Create.Array($$createType10); +const $$createType12 = database$0.ConnectionConfig.createFrom; +const $$createType13 = $Create.Array($$createType12); +const $$createType14 = storage$0.ConnectionFolder.createFrom; +const $$createType15 = $Create.Array($$createType14); +const $$createType16 = database$0.SavedQuery.createFrom; +const $$createType17 = $Create.Array($$createType16); +const $$createType18 = database$0.SchemaInfo.createFrom; +const $$createType19 = $Create.Array($$createType18); +const $$createType20 = database$0.TableInfo.createFrom; +const $$createType21 = $Create.Array($$createType20); +const $$createType22 = database$0.SchemaBundle.createFrom; +const $$createType23 = storage$0.SettingsStore.createFrom; +const $$createType24 = $Create.Nullable($$createType23); diff --git a/frontend/bindings/xensql/internal/app/index.ts b/frontend/bindings/xensql/internal/app/index.ts index 9013391..2ba194c 100644 --- a/frontend/bindings/xensql/internal/app/index.ts +++ b/frontend/bindings/xensql/internal/app/index.ts @@ -7,5 +7,6 @@ export { }; export { - AppInfo + AppInfo, + PathDefaults } from "./models.js"; diff --git a/frontend/bindings/xensql/internal/app/models.ts b/frontend/bindings/xensql/internal/app/models.ts index 7c28a22..03197ef 100644 --- a/frontend/bindings/xensql/internal/app/models.ts +++ b/frontend/bindings/xensql/internal/app/models.ts @@ -49,3 +49,37 @@ export class AppInfo { return new AppInfo($$parsedSource as Partial); } } + +/** + * PathDefaults lets the UI show example paths that match the host, not one platform's convention. + */ +export class PathDefaults { + "platform": string; + "separator": string; + + /** + * SSHKey and SSHKnownHosts are empty when the home directory cannot be resolved. + */ + "sshKey"?: string; + "sshKnownHosts"?: string; + + /** Creates a new PathDefaults instance. */ + constructor($$source: Partial = {}) { + if (!("platform" in $$source)) { + this["platform"] = ""; + } + if (!("separator" in $$source)) { + this["separator"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PathDefaults instance from a string or object. + */ + static createFrom($$source: any = {}): PathDefaults { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new PathDefaults($$parsedSource as Partial); + } +} diff --git a/frontend/bindings/xensql/internal/database/index.ts b/frontend/bindings/xensql/internal/database/index.ts index 01650bf..8aee94e 100644 --- a/frontend/bindings/xensql/internal/database/index.ts +++ b/frontend/bindings/xensql/internal/database/index.ts @@ -10,6 +10,8 @@ export { QueryResult, RowDelete, RowUpdate, + SSHAuthMethod, + SSHConfig, SavedQuery, SchemaBundle, SchemaInfo, diff --git a/frontend/bindings/xensql/internal/database/models.ts b/frontend/bindings/xensql/internal/database/models.ts index 42f98d2..6d6443d 100644 --- a/frontend/bindings/xensql/internal/database/models.ts +++ b/frontend/bindings/xensql/internal/database/models.ts @@ -74,6 +74,11 @@ export class ConnectionConfig { */ "readOnly"?: boolean; + /** + * SSH tunnel (PostgreSQL / MySQL / MariaDB). Host/Port above are resolved from the bastion. + */ + "ssh"?: SSHConfig; + /** Creates a new ConnectionConfig instance. */ constructor($$source: Partial = {}) { if (!("id" in $$source)) { @@ -96,7 +101,11 @@ export class ConnectionConfig { * Creates a new ConnectionConfig instance from a string or object. */ static createFrom($$source: any = {}): ConnectionConfig { + const $$createField14_0 = $$createType0; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("ssh" in $$parsedSource) { + $$parsedSource["ssh"] = $$createField14_0($$parsedSource["ssh"]); + } return new ConnectionConfig($$parsedSource as Partial); } } @@ -232,10 +241,10 @@ export class QueryResult { * Creates a new QueryResult instance from a string or object. */ static createFrom($$source: any = {}): QueryResult { - const $$createField0_0 = $$createType0; - const $$createField1_0 = $$createType0; - const $$createField2_0 = $$createType2; - const $$createField7_0 = $$createType0; + const $$createField0_0 = $$createType1; + const $$createField1_0 = $$createType1; + const $$createField2_0 = $$createType3; + const $$createField7_0 = $$createType1; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("columns" in $$parsedSource) { $$parsedSource["columns"] = $$createField0_0($$parsedSource["columns"]); @@ -277,7 +286,7 @@ export class RowDelete { * Creates a new RowDelete instance from a string or object. */ static createFrom($$source: any = {}): RowDelete { - const $$createField2_0 = $$createType4; + const $$createField2_0 = $$createType5; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("primaryKeys" in $$parsedSource) { $$parsedSource["primaryKeys"] = $$createField2_0($$parsedSource["primaryKeys"]); @@ -314,8 +323,8 @@ export class RowUpdate { * Creates a new RowUpdate instance from a string or object. */ static createFrom($$source: any = {}): RowUpdate { - const $$createField2_0 = $$createType3; - const $$createField3_0 = $$createType3; + const $$createField2_0 = $$createType4; + const $$createField3_0 = $$createType4; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("primaryKey" in $$parsedSource) { $$parsedSource["primaryKey"] = $$createField2_0($$parsedSource["primaryKey"]); @@ -327,6 +336,52 @@ export class RowUpdate { } } +export enum SSHAuthMethod { + /** + * The Go zero value for the underlying type of the enum. + */ + $zero = "", + + SSHAuthPassword = "password", + SSHAuthKey = "key", + SSHAuthAgent = "agent", +}; + +export class SSHConfig { + "enabled"?: boolean; + "host"?: string; + "port"?: number; + "username"?: string; + "auth"?: SSHAuthMethod; + "password"?: string; + "keyPath"?: string; + "passphrase"?: string; + + /** + * KnownHosts overrides the default ~/.ssh/known_hosts. + */ + "knownHosts"?: string; + + /** + * IgnoreHostKey accepts any bastion key, leaving the hop open to interception. + */ + "ignoreHostKey"?: boolean; + + /** Creates a new SSHConfig instance. */ + constructor($$source: Partial = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new SSHConfig instance from a string or object. + */ + static createFrom($$source: any = {}): SSHConfig { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SSHConfig($$parsedSource as Partial); + } +} + export class SavedQuery { "id": string; "name": string; @@ -389,9 +444,9 @@ export class SchemaBundle { * Creates a new SchemaBundle instance from a string or object. */ static createFrom($$source: any = {}): SchemaBundle { - const $$createField0_0 = $$createType5; - const $$createField1_0 = $$createType7; - const $$createField2_0 = $$createType9; + const $$createField0_0 = $$createType6; + const $$createField1_0 = $$createType8; + const $$createField2_0 = $$createType10; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("status" in $$parsedSource) { $$parsedSource["status"] = $$createField0_0($$parsedSource["status"]); @@ -447,7 +502,7 @@ export class SchemaTables { * Creates a new SchemaTables instance from a string or object. */ static createFrom($$source: any = {}): SchemaTables { - const $$createField1_0 = $$createType11; + const $$createField1_0 = $$createType12; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("tables" in $$parsedSource) { $$parsedSource["tables"] = $$createField1_0($$parsedSource["tables"]); @@ -522,15 +577,16 @@ export class TableInfo { } // Private type creation functions -const $$createType0 = $Create.Array($Create.Any); +const $$createType0 = SSHConfig.createFrom; const $$createType1 = $Create.Array($Create.Any); -const $$createType2 = $Create.Array($$createType1); -const $$createType3 = $Create.Map($Create.Any, $Create.Any); -const $$createType4 = $Create.Array($$createType3); -const $$createType5 = ConnectionStatus.createFrom; -const $$createType6 = SchemaInfo.createFrom; -const $$createType7 = $Create.Array($$createType6); -const $$createType8 = SchemaTables.createFrom; -const $$createType9 = $Create.Array($$createType8); -const $$createType10 = TableInfo.createFrom; -const $$createType11 = $Create.Array($$createType10); +const $$createType2 = $Create.Array($Create.Any); +const $$createType3 = $Create.Array($$createType2); +const $$createType4 = $Create.Map($Create.Any, $Create.Any); +const $$createType5 = $Create.Array($$createType4); +const $$createType6 = ConnectionStatus.createFrom; +const $$createType7 = SchemaInfo.createFrom; +const $$createType8 = $Create.Array($$createType7); +const $$createType9 = SchemaTables.createFrom; +const $$createType10 = $Create.Array($$createType9); +const $$createType11 = TableInfo.createFrom; +const $$createType12 = $Create.Array($$createType11); diff --git a/frontend/src/features/connections/ConnectionDialog.tsx b/frontend/src/features/connections/ConnectionDialog.tsx index 3c33d12..27e6a3d 100644 --- a/frontend/src/features/connections/ConnectionDialog.tsx +++ b/frontend/src/features/connections/ConnectionDialog.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Modal } from '@/shared/components/Modal'; +import { usePathDefaults } from '@/shared/hooks/usePathDefaults'; import { api } from '@/shared/lib/api'; import { appToast } from '@/shared/lib/appToast'; import { formatError } from '@/shared/lib/normalize'; -import type { ConnectionConfig, DriverType } from '@/types'; -import { DEFAULT_COLORS, DEFAULT_CONNECTION_COLOR } from '@/types'; +import { isWindows } from '@/shared/lib/pathDefaults'; +import type { ConnectionConfig, DriverType, SSHAuthMethod, SSHConfig } from '@/types'; +import { DEFAULT_COLORS, DEFAULT_CONNECTION_COLOR, DEFAULT_SSH_PORT } from '@/types'; interface Props { connection?: ConnectionConfig | null; @@ -51,9 +53,36 @@ export function ConnectionDialog({ connection, onClose, onSaved }: Props) { const [saving, setSaving] = useState(false); const network = isNetworkDriver(form.driver); + const ssh: SSHConfig = form.ssh ?? {}; + const sshAuth: SSHAuthMethod = ssh.auth ?? 'key'; + + const paths = usePathDefaults(); + const filePlaceholder = isWindows(paths) + ? t('connection.filePlaceholderWindows') + : t('connection.filePlaceholderUnix'); const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + const updateSSH = (patch: Partial) => setForm((f) => ({ ...f, ssh: { ...f.ssh, ...patch } })); + + const handlePickSSHKey = async () => { + try { + const path = await api.pickSSHKeyFile(); + if (path) updateSSH({ keyPath: path }); + } catch { + /* cancelled */ + } + }; + + const handlePickKnownHosts = async () => { + try { + const path = await api.pickKnownHostsFile(); + if (path) updateSSH({ knownHosts: path }); + } catch { + /* cancelled */ + } + }; + const handleDriverChange = (driver: DriverType) => { update({ driver, ...defaultsForDriver(driver) }); }; @@ -173,7 +202,7 @@ export function ConnectionDialog({ connection, onClose, onSaved }: Props) { id="conn-file" value={form.filePath || ''} onChange={(e) => update({ filePath: e.target.value })} - placeholder={t('connection.filePlaceholder')} + placeholder={filePlaceholder} />
+
+
+ +

{t('connection.sshTunnelHint')}

+
+ {ssh.enabled && ( + <> +
+
+ + updateSSH({ host: e.target.value })} + placeholder={t('connection.sshHostPlaceholder')} + /> +
+
+ + updateSSH({ port: parseInt(e.target.value, 10) || DEFAULT_SSH_PORT })} + /> +
+
+
+
+ + updateSSH({ username: e.target.value })} + /> +
+
+ + +
+
+ {sshAuth === 'key' && ( + <> +
+ +
+ updateSSH({ keyPath: e.target.value })} + placeholder={paths.sshKey} + /> + +
+
+
+ + updateSSH({ passphrase: e.target.value })} + placeholder={t('connection.sshPassphrasePlaceholder')} + /> +
+ + )} + {sshAuth === 'password' && ( +
+ + updateSSH({ password: e.target.value })} + /> +
+ )} + {sshAuth === 'agent' &&

{t('connection.sshAuthAgentHint')}

} +
+ +

{t('connection.sshIgnoreHostKeyHint')}

+
+ {!ssh.ignoreHostKey && ( +
+ +
+ updateSSH({ knownHosts: e.target.value })} + placeholder={paths.sshKnownHosts} + /> + +
+

{t('connection.sshKnownHostsHint', { path: paths.sshKnownHosts })}

+
+ )} + + )} +
)} {error &&
{error}
} diff --git a/frontend/src/i18n/locales.test.ts b/frontend/src/i18n/locales.test.ts new file mode 100644 index 0000000..ecebbaa --- /dev/null +++ b/frontend/src/i18n/locales.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import bg from '@/i18n/locales/bg.json'; +import de from '@/i18n/locales/de.json'; +import en from '@/i18n/locales/en.json'; + +type Bundle = Record; + +function flatten(bundle: Bundle, prefix = ''): string[] { + return Object.entries(bundle).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return value !== null && typeof value === 'object' ? flatten(value as Bundle, path) : [path]; + }); +} + +const translations = { de, bg }; + +describe('locales', () => { + const english = flatten(en).sort(); + + it('has keys in English', () => { + expect(english.length).toBeGreaterThan(0); + }); + + for (const [language, bundle] of Object.entries(translations)) { + describe(language, () => { + const keys = flatten(bundle as Bundle).sort(); + + it('translates every English key', () => { + expect(english.filter((key) => !keys.includes(key))).toEqual([]); + }); + + it('has no keys English is missing', () => { + expect(keys.filter((key) => !english.includes(key))).toEqual([]); + }); + + it('leaves no value empty', () => { + const empty = flatten(bundle as Bundle).filter((key) => { + const value = key.split('.').reduce((node, part) => (node as Bundle)?.[part], bundle); + return typeof value === 'string' && value.trim() === ''; + }); + expect(empty).toEqual([]); + }); + }); + } +}); diff --git a/frontend/src/i18n/locales/bg.json b/frontend/src/i18n/locales/bg.json index a473131..deb5fac 100644 --- a/frontend/src/i18n/locales/bg.json +++ b/frontend/src/i18n/locales/bg.json @@ -400,7 +400,8 @@ "readOnlyHint": "Блокира INSERT, UPDATE, DELETE, DDL и редактиране на редове в таблицата с резултати.", "tabColor": "Цвят на таба", "file": "Файл на базата данни", - "filePlaceholder": "C:\\път\\към\\база.db", + "filePlaceholderWindows": "C:\\път\\към\\база.db", + "filePlaceholderUnix": "/път/към/база.db", "host": "Хост", "port": "Порт", "databaseName": "Име на базата данни", @@ -420,7 +421,26 @@ "sslDisable": "disable", "sslRequire": "require", "sslVerifyFull": "verify-full", - "dropSqlite": "Пуснете SQLite база данни тук" + "dropSqlite": "Пуснете SQLite база данни тук", + "sshTunnel": "Използвай SSH тунел", + "sshTunnelHint": "Хостът на базата се разрешава от bastion машината.", + "sshHost": "SSH хост", + "sshHostPlaceholder": "bastion.example.com", + "sshPort": "SSH порт", + "sshUsername": "SSH потребител", + "sshAuth": "Удостоверяване", + "sshAuthKey": "Частен ключ", + "sshAuthPassword": "Парола", + "sshAuthAgent": "SSH агент", + "sshAuthAgentHint": "Използва ключовете в работещия ssh-agent.", + "sshKeyPath": "Файл с частен ключ", + "sshPassphrase": "Парола на ключа", + "sshPassphrasePlaceholder": "Само ако ключът е криптиран", + "sshPassword": "SSH парола", + "sshIgnoreHostKey": "Пропускане на проверката на ключа на хоста", + "sshIgnoreHostKeyHint": "Приема всеки ключ на bastion и позволява прихващане.", + "sshKnownHosts": "Файл known_hosts", + "sshKnownHostsHint": "По подразбиране: {{path}}" }, "jsonViewer": { "title": "JSON ПРЕГЛЕД", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 660577e..24df412 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -400,7 +400,8 @@ "readOnlyHint": "Blockiert INSERT, UPDATE, DELETE, DDL und Bearbeiten von Zeilen im Ergebnisraster.", "tabColor": "Tab-Farbe", "file": "Datenbankdatei", - "filePlaceholder": "C:\\Pfad\\zur\\datenbank.db", + "filePlaceholderWindows": "C:\\Pfad\\zur\\datenbank.db", + "filePlaceholderUnix": "/pfad/zur/datenbank.db", "host": "Host", "port": "Port", "databaseName": "Datenbankname", @@ -420,7 +421,26 @@ "sslDisable": "disable", "sslRequire": "require", "sslVerifyFull": "verify-full", - "dropSqlite": "SQLite-Datenbank hierher ziehen" + "dropSqlite": "SQLite-Datenbank hierher ziehen", + "sshTunnel": "SSH-Tunnel verwenden", + "sshTunnelHint": "Der Datenbank-Host wird vom Bastion-Host aus aufgelöst.", + "sshHost": "SSH-Host", + "sshHostPlaceholder": "bastion.example.com", + "sshPort": "SSH-Port", + "sshUsername": "SSH-Benutzername", + "sshAuth": "Authentifizierung", + "sshAuthKey": "Privater Schlüssel", + "sshAuthPassword": "Passwort", + "sshAuthAgent": "SSH-Agent", + "sshAuthAgentHint": "Verwendet die Schlüssel im laufenden ssh-agent.", + "sshKeyPath": "Datei mit privatem Schlüssel", + "sshPassphrase": "Passphrase des Schlüssels", + "sshPassphrasePlaceholder": "Nur wenn der Schlüssel verschlüsselt ist", + "sshPassword": "SSH-Passwort", + "sshIgnoreHostKey": "Host-Key-Prüfung überspringen", + "sshIgnoreHostKeyHint": "Akzeptiert jeden Bastion-Schlüssel und ermöglicht so Abhören.", + "sshKnownHosts": "known_hosts-Datei", + "sshKnownHostsHint": "Standard: {{path}}" }, "jsonViewer": { "title": "JSON-ANSICHT", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f8fed0b..ba6a188 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -400,7 +400,8 @@ "readOnlyHint": "Blocks INSERT, UPDATE, DELETE, DDL and editing rows in the results grid.", "tabColor": "Tab Color", "file": "Database File", - "filePlaceholder": "C:\\path\\to\\database.db", + "filePlaceholderWindows": "C:\\path\\to\\database.db", + "filePlaceholderUnix": "/path/to/database.db", "host": "Host", "port": "Port", "databaseName": "Database name", @@ -420,7 +421,26 @@ "sslDisable": "disable", "sslRequire": "require", "sslVerifyFull": "verify-full", - "dropSqlite": "Drop SQLite database to open" + "dropSqlite": "Drop SQLite database to open", + "sshTunnel": "Use an SSH tunnel", + "sshTunnelHint": "The database host is resolved from the bastion.", + "sshHost": "SSH Host", + "sshHostPlaceholder": "bastion.example.com", + "sshPort": "SSH Port", + "sshUsername": "SSH Username", + "sshAuth": "Authentication", + "sshAuthKey": "Private key", + "sshAuthPassword": "Password", + "sshAuthAgent": "SSH agent", + "sshAuthAgentHint": "Uses the keys in your running ssh-agent.", + "sshKeyPath": "Private Key File", + "sshPassphrase": "Key Passphrase", + "sshPassphrasePlaceholder": "Only if the key is encrypted", + "sshPassword": "SSH Password", + "sshIgnoreHostKey": "Skip host key check", + "sshIgnoreHostKeyHint": "Accepts any bastion key, allowing interception.", + "sshKnownHosts": "known_hosts File", + "sshKnownHostsHint": "Defaults to {{path}}" }, "jsonViewer": { "title": "JSON VIEWER", diff --git a/frontend/src/shared/hooks/usePathDefaults.ts b/frontend/src/shared/hooks/usePathDefaults.ts new file mode 100644 index 0000000..daf6ae3 --- /dev/null +++ b/frontend/src/shared/hooks/usePathDefaults.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/shared/lib/api'; +import { DEFAULT_PATH_DEFAULTS, type PathDefaults } from '@/shared/lib/pathDefaults'; + +// Cached for the session; the host cannot change while the app is open. +let cached: PathDefaults | null = null; + +export function usePathDefaults(): PathDefaults { + const [defaults, setDefaults] = useState(cached ?? DEFAULT_PATH_DEFAULTS); + useEffect(() => { + if (cached) return; + void api + .getPathDefaults() + .then((value) => { + cached = value; + setDefaults(value); + }) + .catch(() => setDefaults(DEFAULT_PATH_DEFAULTS)); + }, []); + return defaults; +} diff --git a/frontend/src/shared/lib/api.ts b/frontend/src/shared/lib/api.ts index bd5338b..617bd94 100644 --- a/frontend/src/shared/lib/api.ts +++ b/frontend/src/shared/lib/api.ts @@ -18,6 +18,7 @@ import { GetAppInfo, GetConnectionStatus, GetEditorSession, + GetPathDefaults, GetPendingFile, GetQueryHistory, InsertRow, @@ -30,7 +31,9 @@ import { ListTables, LoadSchemaData, PickExportSavePath, + PickKnownHostsFile, PickSQLiteFile, + PickSSHKeyFile, QueryTableStream, ReorderConnections, RollbackTransaction, @@ -81,6 +84,8 @@ export const api = { saveFolder: (f: ConnectionFolder): Promise => cast(SaveFolder(f as never)), deleteFolder: (id: string): Promise => DeleteFolder(id), pickSQLiteFile: (): Promise => PickSQLiteFile(), + pickSSHKeyFile: (): Promise => PickSSHKeyFile(), + pickKnownHostsFile: (): Promise => PickKnownHostsFile(), listSchemas: async (connId: string) => normalizeSchemas(await ListSchemas(connId)), listTables: async (connId: string, schema: string) => normalizeTables(await ListTables(connId, schema)), listColumns: async (connId: string, schema: string, table: string) => @@ -120,6 +125,7 @@ export const api = { pickExportSavePath: (ext: string): Promise => PickExportSavePath(ext), saveTextFile: (path: string, content: string): Promise => SaveTextFile(path, content), getAppInfo: (): Promise => cast(GetAppInfo()), + getPathDefaults: (): Promise => cast(GetPathDefaults()), // Fire-and-forget: the native update window drives the rest of the flow. checkForUpdates: (): Promise => CheckForUpdates(), getPendingFile: (): Promise<{ filePath: string; name: string } | null> => cast(GetPendingFile()), diff --git a/frontend/src/shared/lib/pathDefaults.ts b/frontend/src/shared/lib/pathDefaults.ts new file mode 100644 index 0000000..7ab3e88 --- /dev/null +++ b/frontend/src/shared/lib/pathDefaults.ts @@ -0,0 +1,18 @@ +export interface PathDefaults { + platform: string; + separator: string; + sshKey?: string; + sshKnownHosts?: string; +} + +/** Fallback when the Go binding is unavailable (dev in browser). */ +export const DEFAULT_PATH_DEFAULTS: PathDefaults = { + platform: '', + separator: '/', + sshKey: '~/.ssh/id_ed25519', + sshKnownHosts: '~/.ssh/known_hosts', +}; + +export function isWindows(defaults: PathDefaults): boolean { + return defaults.platform === 'windows'; +} diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css index 486d319..3795b15 100644 --- a/frontend/src/styles/forms.css +++ b/frontend/src/styles/forms.css @@ -87,6 +87,15 @@ gap: var(--space-10); } +/* Groups related fields under a divider. */ +.form-section { + display: flex; + flex-direction: column; + gap: var(--space-12); + padding-top: var(--space-12); + border-top: 1px solid var(--border); +} + .color-picker { display: grid; grid-template-columns: repeat(15, var(--space-24)); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7a40df8..9278bdf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -15,8 +15,26 @@ export interface ConnectionConfig { sslMode?: string; schema?: string; readOnly?: boolean; + ssh?: SSHConfig; } +export type SSHAuthMethod = 'password' | 'key' | 'agent'; + +export interface SSHConfig { + enabled?: boolean; + host?: string; + port?: number; + username?: string; + auth?: SSHAuthMethod; + password?: string; + keyPath?: string; + passphrase?: string; + knownHosts?: string; + ignoreHostKey?: boolean; +} + +export const DEFAULT_SSH_PORT = 22; + export interface ConnectionFolder { id: string; name: string; diff --git a/go.mod b/go.mod index 3a71101..8856ef5 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/wailsapp/wails/v3 v3.0.0-beta.3 + golang.org/x/crypto v0.53.0 modernc.org/sqlite v1.56.0 ) diff --git a/go.sum b/go.sum index 75b4cc5..d606031 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA= github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -62,6 +64,8 @@ golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= diff --git a/internal/app/app_files.go b/internal/app/app_files.go index e65ecfd..cbdb313 100644 --- a/internal/app/app_files.go +++ b/internal/app/app_files.go @@ -67,6 +67,24 @@ func (a *App) PickSQLiteFile() (string, error) { PromptForSingleSelection() } +func (a *App) PickSSHKeyFile() (string, error) { + return a.pickSSHFile("Select SSH private key") +} + +func (a *App) PickKnownHostsFile() (string, error) { + return a.pickSSHFile("Select known_hosts file") +} + +// pickSSHFile shows hidden files because both targets live in ~/.ssh. +func (a *App) pickSSHFile(title string) (string, error) { + return a.app.Dialog.OpenFile(). + SetTitle(title). + CanChooseFiles(true). + ShowHiddenFiles(true). + AddFilter("All Files", "*.*"). + PromptForSingleSelection() +} + func (a *App) PickExportSavePath(ext string) (string, error) { if ext == "" { ext = "txt" diff --git a/internal/app/app_paths.go b/internal/app/app_paths.go new file mode 100644 index 0000000..b0cb25d --- /dev/null +++ b/internal/app/app_paths.go @@ -0,0 +1,30 @@ +package app + +import ( + "os" + "path/filepath" + "runtime" +) + +// PathDefaults lets the UI show example paths that match the host, not one platform's convention. +type PathDefaults struct { + Platform string `json:"platform"` + Separator string `json:"separator"` + // SSHKey and SSHKnownHosts are empty when the home directory cannot be resolved. + SSHKey string `json:"sshKey,omitempty"` + SSHKnownHosts string `json:"sshKnownHosts,omitempty"` +} + +func (a *App) GetPathDefaults() PathDefaults { + d := PathDefaults{ + Platform: runtime.GOOS, + Separator: string(os.PathSeparator), + } + home, err := os.UserHomeDir() + if err != nil { + return d + } + d.SSHKey = filepath.Join(home, ".ssh", "id_ed25519") + d.SSHKnownHosts = filepath.Join(home, ".ssh", "known_hosts") + return d +} diff --git a/internal/app/app_paths_test.go b/internal/app/app_paths_test.go new file mode 100644 index 0000000..0230e44 --- /dev/null +++ b/internal/app/app_paths_test.go @@ -0,0 +1,53 @@ +package app + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGetPathDefaultsMatchesTheHost(t *testing.T) { + a := &App{} + got := a.GetPathDefaults() + + if got.Platform != runtime.GOOS { + t.Fatalf("platform = %q, want %q", got.Platform, runtime.GOOS) + } + if got.Separator != string(os.PathSeparator) { + t.Fatalf("separator = %q, want %q", got.Separator, string(os.PathSeparator)) + } + + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory on this host") + } + if want := filepath.Join(home, ".ssh", "id_ed25519"); got.SSHKey != want { + t.Fatalf("sshKey = %q, want %q", got.SSHKey, want) + } + if want := filepath.Join(home, ".ssh", "known_hosts"); got.SSHKnownHosts != want { + t.Fatalf("sshKnownHosts = %q, want %q", got.SSHKnownHosts, want) + } +} + +// The UI must never show another platform's path style. +func TestGetPathDefaultsUsesHostPathStyle(t *testing.T) { + got := (&App{}).GetPathDefaults() + if got.SSHKey == "" { + t.Skip("no home directory on this host") + } + + foreign := "/" + if os.PathSeparator == '/' { + foreign = `\` + } + for name, path := range map[string]string{"sshKey": got.SSHKey, "sshKnownHosts": got.SSHKnownHosts} { + if !strings.Contains(path, got.Separator) { + t.Fatalf("%s = %q does not use the host separator %q", name, path, got.Separator) + } + if strings.Contains(path, foreign) { + t.Fatalf("%s = %q contains the foreign separator %q", name, path, foreign) + } + } +} diff --git a/internal/app/e2e_ssh_test.go b/internal/app/e2e_ssh_test.go new file mode 100644 index 0000000..178397f --- /dev/null +++ b/internal/app/e2e_ssh_test.go @@ -0,0 +1,284 @@ +//go:build e2e + +package app + +import ( + "crypto/ed25519" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + + "xensql/internal/database" +) + +// These point the real drivers at the real engines through an in-process SSH bastion, exercising +// the pgx DialFunc and go-sql-driver RegisterDialContext wiring end to end. + +type bastion struct { + addr string + hostKey ssh.PublicKey + user string + pass string +} + +func startBastion(t *testing.T) bastion { + t.Helper() + + _, hostPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + hostSigner, err := ssh.NewSignerFromKey(hostPriv) + if err != nil { + t.Fatalf("host signer: %v", err) + } + + b := bastion{hostKey: hostSigner.PublicKey(), user: "tunnel", pass: "tunnel-pw"} + cfg := &ssh.ServerConfig{ + PasswordCallback: func(meta ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if meta.User() != b.user || string(pass) != b.pass { + return nil, errors.New("denied") + } + return nil, nil + }, + } + cfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveBastion(conn, cfg) + } + }() + + b.addr = ln.Addr().String() + return b +} + +func serveBastion(conn net.Conn, cfg *ssh.ServerConfig) { + sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) + if err != nil { + _ = conn.Close() + return + } + defer sshConn.Close() + go ssh.DiscardRequests(reqs) + for newCh := range chans { + if newCh.ChannelType() != "direct-tcpip" { + _ = newCh.Reject(ssh.UnknownChannelType, "only direct-tcpip") + continue + } + var payload struct { + DestHost string + DestPort uint32 + SrcHost string + SrcPort uint32 + } + if err := ssh.Unmarshal(newCh.ExtraData(), &payload); err != nil { + _ = newCh.Reject(ssh.ConnectionFailed, "bad payload") + continue + } + target, err := net.Dial("tcp", net.JoinHostPort(payload.DestHost, fmt.Sprint(payload.DestPort))) + if err != nil { + _ = newCh.Reject(ssh.ConnectionFailed, err.Error()) + continue + } + ch, chReqs, err := newCh.Accept() + if err != nil { + _ = target.Close() + continue + } + go ssh.DiscardRequests(chReqs) + go func() { + defer ch.Close() + defer target.Close() + go func() { _, _ = io.Copy(target, ch) }() + _, _ = io.Copy(ch, target) + }() + } +} + +// knownHostsFor exercises real host key verification rather than the IgnoreHostKey escape hatch. +func (b bastion) knownHostsFor(t *testing.T) string { + t.Helper() + host, port, err := net.SplitHostPort(b.addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + path := filepath.Join(t.TempDir(), "known_hosts") + entry := fmt.Sprintf("[%s]:%s %s", host, port, string(ssh.MarshalAuthorizedKey(b.hostKey))) + if err := os.WriteFile(path, []byte(entry), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + return path +} + +func (b bastion) sshConfig(t *testing.T) database.SSHConfig { + t.Helper() + host, portStr, err := net.SplitHostPort(b.addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + var port int + if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { + t.Fatalf("parse port: %v", err) + } + return database.SSHConfig{ + Enabled: true, + Host: host, + Port: port, + Username: b.user, + Auth: database.SSHAuthPassword, + Password: b.pass, + KnownHosts: b.knownHostsFor(t), + } +} + +// tunnelledConn saves a connection that reaches the engine through the bastion. +func tunnelledConn(t *testing.T, a *App, e engine, ssh database.SSHConfig) string { + t.Helper() + cfg := e.config() + cfg.Name = "e2e-ssh-" + e.name + cfg.SSH = ssh + saved, err := a.SaveConnection(cfg) + if err != nil { + t.Fatalf("save tunnelled connection: %v", err) + } + return saved.ID +} + +func TestSSHTunnelQueriesEachEngine(t *testing.T) { + for _, e := range allEngines() { + t.Run(e.name, func(t *testing.T) { + a := appForTest(t) + requireEngine(t, a, e) // skips when the engine is not up + + b := startBastion(t) + connID := tunnelledConn(t, a, e, b.sshConfig(t)) + + if err := a.Connect(connID); err != nil { + t.Fatalf("connect through the tunnel: %v", err) + } + defer a.Disconnect(connID) + + res, err := a.ExecuteQuery(connID, "SELECT 1 AS one") + if err != nil { + t.Fatalf("query through the tunnel: %v", err) + } + if len(res.Rows) != 1 || len(res.Rows[0]) != 1 { + t.Fatalf("expected a single 1x1 row, got %+v", res.Rows) + } + + // The schema explorer shares the session, so it must survive the tunnel too. + if _, err := a.LoadSchemaData(connID); err != nil { + t.Fatalf("load schema through the tunnel: %v", err) + } + + status, err := a.GetConnectionStatus(connID) + if err != nil { + t.Fatalf("status: %v", err) + } + if !status.Connected { + t.Fatal("status reports the tunnelled connection as disconnected") + } + }) + } +} + +// Proves the connection is bidirectional and pooled, not good for a single round trip. +func TestSSHTunnelSurvivesWritesAndReconnect(t *testing.T) { + for _, e := range allEngines() { + t.Run(e.name, func(t *testing.T) { + a := appForTest(t) + requireEngine(t, a, e) + + b := startBastion(t) + connID := tunnelledConn(t, a, e, b.sshConfig(t)) + if err := a.Connect(connID); err != nil { + t.Fatalf("connect: %v", err) + } + + table := uniqueTable("ssh_tunnel") + mustExec(t, a, connID, e.autoPKTable(table)) + defer func() { _, _ = a.ExecuteQuery(connID, "DROP TABLE "+table) }() + + mustExec(t, a, connID, fmt.Sprintf("INSERT INTO %s (name) VALUES ('through-the-tunnel')", table)) + res, err := a.ExecuteQuery(connID, "SELECT name FROM "+table) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(res.Rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(res.Rows)) + } + + // Disconnect tears the tunnel down; reconnecting must build a fresh one. + a.Disconnect(connID) + if err := a.Connect(connID); err != nil { + t.Fatalf("reconnect through a new tunnel: %v", err) + } + defer a.Disconnect(connID) + if _, err := a.ExecuteQuery(connID, "SELECT name FROM "+table); err != nil { + t.Fatalf("query after reconnect: %v", err) + } + }) + } +} + +func TestSSHTunnelRejectsUnknownBastionKey(t *testing.T) { + e := allEngines()[0] + a := appForTest(t) + requireEngine(t, a, e) + + b := startBastion(t) + sshCfg := b.sshConfig(t) + // An empty known_hosts leaves the bastion unrecognised. + empty := filepath.Join(t.TempDir(), "known_hosts") + if err := os.WriteFile(empty, []byte(""), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + sshCfg.KnownHosts = empty + + connID := tunnelledConn(t, a, e, sshCfg) + err := a.Connect(connID) + if err == nil { + a.Disconnect(connID) + t.Fatal("expected the unknown bastion key to be rejected") + } + if !strings.Contains(err.Error(), "ssh-keyscan") { + t.Fatalf("error should tell the user how to fix it, got: %v", err) + } +} + +func TestSSHTunnelBadCredentialsDoNotConnect(t *testing.T) { + e := allEngines()[0] + a := appForTest(t) + requireEngine(t, a, e) + + b := startBastion(t) + sshCfg := b.sshConfig(t) + sshCfg.Password = "wrong" + + connID := tunnelledConn(t, a, e, sshCfg) + if err := a.Connect(connID); err == nil { + a.Disconnect(connID) + t.Fatal("expected bad bastion credentials to fail the connection") + } + if a.IsConnected(connID) { + t.Fatal("a failed tunnel must not leave the connection registered") + } +} diff --git a/internal/database/config.go b/internal/database/config.go index c09b5a3..c6d3137 100644 --- a/internal/database/config.go +++ b/internal/database/config.go @@ -15,6 +15,24 @@ func NormalizeConnectionConfig(cfg *ConnectionConfig) { cfg.Username = strings.TrimSpace(cfg.Username) cfg.SSLMode = strings.TrimSpace(cfg.SSLMode) cfg.Schema = strings.TrimSpace(cfg.Schema) + normalizeSSHConfig(&cfg.SSH) +} + +func normalizeSSHConfig(ssh *SSHConfig) { + ssh.Host = strings.TrimSpace(ssh.Host) + ssh.Username = strings.TrimSpace(ssh.Username) + ssh.KeyPath = strings.TrimSpace(ssh.KeyPath) + ssh.KnownHosts = strings.TrimSpace(ssh.KnownHosts) + // Defaults only once the tunnel is on, so direct connections keep an empty block. + if !ssh.Enabled { + return + } + if ssh.Port == 0 { + ssh.Port = 22 + } + if ssh.Auth == "" { + ssh.Auth = SSHAuthKey + } } func ValidateConnectionConfig(cfg ConnectionConfig) error { @@ -46,6 +64,35 @@ func ValidateConnectionConfig(cfg ConnectionConfig) error { default: return fmt.Errorf("unsupported driver: %s", cfg.Driver) } + // SQLite reads a local file, so leftover SSH settings cannot affect it. + if cfg.Driver != DriverSQLite { + return validateSSHConfig(cfg.SSH) + } + return nil +} + +func validateSSHConfig(ssh SSHConfig) error { + if !ssh.Enabled { + return nil + } + if ssh.Host == "" { + return fmt.Errorf("SSH host is required") + } + if ssh.Username == "" { + return fmt.Errorf("SSH username is required") + } + if ssh.Port < 0 || ssh.Port > 65535 { + return fmt.Errorf("SSH port must be between 1 and 65535") + } + switch ssh.Auth { + case SSHAuthKey, "": + if ssh.KeyPath == "" { + return fmt.Errorf("SSH private key file is required") + } + case SSHAuthPassword, SSHAuthAgent: + default: + return fmt.Errorf("unsupported SSH authentication method: %s", ssh.Auth) + } return nil } @@ -66,7 +113,7 @@ func DefaultBrowseSchema(cfg ConnectionConfig) string { // SHA-256 digest of connection settings so passwords never appear as plain substrings in logs. func ConfigFingerprint(cfg ConnectionConfig) string { raw := fmt.Sprintf( - "%s|%s|%d|%s|%s|%s|%s|%s|%s|%t", + "%s|%s|%d|%s|%s|%s|%s|%s|%s|%t|%t|%s|%d|%s|%s|%s|%s|%s|%s|%t", cfg.Driver, cfg.Host, cfg.Port, @@ -77,6 +124,17 @@ func ConfigFingerprint(cfg ConnectionConfig) string { cfg.FilePath, cfg.Schema, cfg.ReadOnly, + // SSH changes which server the session reaches, so it must invalidate a pooled session. + cfg.SSH.Enabled, + cfg.SSH.Host, + cfg.SSH.Port, + cfg.SSH.Username, + cfg.SSH.Auth, + cfg.SSH.Password, + cfg.SSH.KeyPath, + cfg.SSH.Passphrase, + cfg.SSH.KnownHosts, + cfg.SSH.IgnoreHostKey, ) sum := sha256.Sum256([]byte(raw)) return hex.EncodeToString(sum[:]) diff --git a/internal/database/config_ssh_test.go b/internal/database/config_ssh_test.go new file mode 100644 index 0000000..b8d7c6a --- /dev/null +++ b/internal/database/config_ssh_test.go @@ -0,0 +1,166 @@ +package database + +import ( + "strings" + "testing" +) + +func baseSSHConn() ConnectionConfig { + return ConnectionConfig{ + Driver: DriverPostgres, + Host: "db.internal", + Database: "app", + Username: "postgres", + } +} + +func TestNormalizeSSHConfigTrimsAndDefaults(t *testing.T) { + cfg := baseSSHConn() + cfg.SSH = SSHConfig{ + Enabled: true, + Host: " bastion.example.com ", + Username: " deploy ", + KeyPath: " ~/.ssh/id_ed25519 ", + KnownHosts: " ~/.ssh/known_hosts ", + } + NormalizeConnectionConfig(&cfg) + + if cfg.SSH.Host != "bastion.example.com" { + t.Fatalf("host not trimmed: %q", cfg.SSH.Host) + } + if cfg.SSH.Username != "deploy" { + t.Fatalf("username not trimmed: %q", cfg.SSH.Username) + } + if cfg.SSH.KeyPath != "~/.ssh/id_ed25519" { + t.Fatalf("key path not trimmed: %q", cfg.SSH.KeyPath) + } + if cfg.SSH.KnownHosts != "~/.ssh/known_hosts" { + t.Fatalf("known hosts not trimmed: %q", cfg.SSH.KnownHosts) + } + if cfg.SSH.Port != 22 { + t.Fatalf("port should default to 22, got %d", cfg.SSH.Port) + } + if cfg.SSH.Auth != SSHAuthKey { + t.Fatalf("auth should default to key, got %q", cfg.SSH.Auth) + } +} + +func TestNormalizeKeepsExplicitSSHPort(t *testing.T) { + cfg := baseSSHConn() + cfg.SSH = SSHConfig{Enabled: true, Host: "b", Username: "u", Port: 2222, Auth: SSHAuthAgent} + NormalizeConnectionConfig(&cfg) + if cfg.SSH.Port != 2222 { + t.Fatalf("explicit port overwritten: %d", cfg.SSH.Port) + } + if cfg.SSH.Auth != SSHAuthAgent { + t.Fatalf("explicit auth overwritten: %q", cfg.SSH.Auth) + } +} + +func TestValidateSSHConfig(t *testing.T) { + tests := []struct { + name string + ssh SSHConfig + wantErr string + }{ + {"disabled ignores empty fields", SSHConfig{}, ""}, + {"disabled ignores invalid fields", SSHConfig{Port: 99999}, ""}, + {"key auth is complete", SSHConfig{Enabled: true, Host: "b", Username: "u", Auth: SSHAuthKey, KeyPath: "/k"}, ""}, + {"agent auth needs no key", SSHConfig{Enabled: true, Host: "b", Username: "u", Auth: SSHAuthAgent}, ""}, + {"password auth needs no key", SSHConfig{Enabled: true, Host: "b", Username: "u", Auth: SSHAuthPassword}, ""}, + {"missing host", SSHConfig{Enabled: true, Username: "u", Auth: SSHAuthAgent}, "SSH host is required"}, + {"missing user", SSHConfig{Enabled: true, Host: "b", Auth: SSHAuthAgent}, "SSH username is required"}, + {"missing key", SSHConfig{Enabled: true, Host: "b", Username: "u", Auth: SSHAuthKey}, "private key file is required"}, + {"empty auth defaults to key", SSHConfig{Enabled: true, Host: "b", Username: "u"}, "private key file is required"}, + {"bad port", SSHConfig{Enabled: true, Host: "b", Username: "u", Auth: SSHAuthAgent, Port: 70000}, "between 1 and 65535"}, + {"bad method", SSHConfig{Enabled: true, Host: "b", Username: "u", Auth: "telepathy"}, "unsupported SSH authentication"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := baseSSHConn() + cfg.SSH = tc.ssh + err := ValidateConnectionConfig(cfg) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected an error containing %q", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got %v, want it to contain %q", err, tc.wantErr) + } + }) + } +} + +// A direct connection keeps an empty SSH block rather than gaining defaults it never uses. +func TestNormalizeLeavesDisabledSSHUntouched(t *testing.T) { + cfg := baseSSHConn() + NormalizeConnectionConfig(&cfg) + if cfg.SSH != (SSHConfig{}) { + t.Fatalf("disabled SSH should stay zero, got %+v", cfg.SSH) + } +} + +// A leftover SSH block from another driver must not block SQLite. +func TestValidateIgnoresSSHForSQLite(t *testing.T) { + cfg := ConnectionConfig{Driver: DriverSQLite, FilePath: "/tmp/app.db"} + cfg.SSH = SSHConfig{Enabled: true} // incomplete on purpose + if err := ValidateConnectionConfig(cfg); err != nil { + t.Fatalf("SQLite should ignore SSH settings, got %v", err) + } +} + +// The pool reuses a session while the fingerprint is unchanged, so every SSH field must alter it. +func TestConfigFingerprintCoversSSHFields(t *testing.T) { + base := baseSSHConn() + base.SSH = SSHConfig{ + Enabled: true, Host: "bastion", Port: 22, Username: "deploy", + Auth: SSHAuthKey, KeyPath: "/keys/id", Passphrase: "pp", + KnownHosts: "/kh", Password: "pw", + } + baseFP := ConfigFingerprint(base) + + mutations := map[string]func(*ConnectionConfig){ + "enabled": func(c *ConnectionConfig) { c.SSH.Enabled = false }, + "host": func(c *ConnectionConfig) { c.SSH.Host = "other" }, + "port": func(c *ConnectionConfig) { c.SSH.Port = 2222 }, + "username": func(c *ConnectionConfig) { c.SSH.Username = "root" }, + "auth": func(c *ConnectionConfig) { c.SSH.Auth = SSHAuthAgent }, + "password": func(c *ConnectionConfig) { c.SSH.Password = "other" }, + "keyPath": func(c *ConnectionConfig) { c.SSH.KeyPath = "/keys/other" }, + "passphrase": func(c *ConnectionConfig) { c.SSH.Passphrase = "other" }, + "knownHosts": func(c *ConnectionConfig) { c.SSH.KnownHosts = "/other" }, + "ignoreHostKey": func(c *ConnectionConfig) { c.SSH.IgnoreHostKey = true }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + cfg := base + mutate(&cfg) + if ConfigFingerprint(cfg) == baseFP { + t.Fatalf("changing SSH %s did not change the fingerprint - the pool would reuse a stale session", name) + } + }) + } + + same := base + if ConfigFingerprint(same) != baseFP { + t.Fatal("fingerprint is not stable for an unchanged config") + } +} + +// Drivers call OpenTunnel unconditionally, so a disabled tunnel must open nothing. +func TestOpenTunnelSkippedWhenDisabled(t *testing.T) { + cfg := baseSSHConn() + cfg.SSH = SSHConfig{Enabled: false, Host: "unreachable.invalid", Username: "u", Auth: SSHAuthAgent} + tunnel, err := OpenTunnel(t.Context(), cfg) + if err != nil { + t.Fatalf("expected no error for a disabled tunnel, got %v", err) + } + if tunnel != nil { + t.Fatal("expected no tunnel for a disabled config") + } +} diff --git a/internal/database/mysql/driver.go b/internal/database/mysql/driver.go index 5048c50..ae8bae7 100644 --- a/internal/database/mysql/driver.go +++ b/internal/database/mysql/driver.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "fmt" + "net" "strings" + "sync/atomic" "time" mysqldriver "github.com/go-sql-driver/mysql" @@ -12,6 +14,10 @@ import ( "xensql/internal/database" ) +// go-sql-driver keys dialers by network name in a process-wide registry, so each tunnelled session +// needs its own name to deregister independently. +var tunnelSeq atomic.Uint64 + var systemSchemas = map[string]bool{ "information_schema": true, "performance_schema": true, @@ -36,14 +42,37 @@ func (d *Driver) Connect(ctx context.Context, cfg database.ConnectionConfig) (da if err := database.ValidateConnectionConfig(cfg); err != nil { return nil, err } + mysqlCfg := buildConfig(cfg) + tunnel, err := database.OpenTunnel(ctx, cfg) + if err != nil { + return nil, err + } + var netName string + if tunnel != nil { + netName = fmt.Sprintf("xensql-ssh-%d", tunnelSeq.Add(1)) + mysqldriver.RegisterDialContext(netName, func(ctx context.Context, addr string) (net.Conn, error) { + return tunnel.DialContext(ctx, "tcp", addr) + }) + // Addr keeps the real hostname, so a verify-full handshake checks the right certificate. + mysqlCfg.Net = netName + } + closeTunnel := func() error { + if tunnel == nil { + return nil + } + mysqldriver.DeregisterDialContext(netName) + return tunnel.Close() + } // Open via the connector so the in-memory TLS config survives - FormatDSN()+sql.Open drops it (plaintext downgrade). - connector, err := mysqldriver.NewConnector(buildConfig(cfg)) + connector, err := mysqldriver.NewConnector(mysqlCfg) if err != nil { + _ = closeTunnel() return nil, err } db := sql.OpenDB(connector) db.SetMaxOpenConns(10) if err := database.PingOrClose(ctx, db, 10*time.Second); err != nil { + _ = closeTunnel() return nil, err } schema := cfg.Schema @@ -59,6 +88,7 @@ func (d *Driver) Connect(ctx context.Context, cfg database.ConnectionConfig) (da ReadOnly: cfg.ReadOnly, RegisterKill: s.registerQueryKill, ListCols: s.ListColumns, + OnClose: closeTunnel, } return s, nil } diff --git a/internal/database/postgres/driver.go b/internal/database/postgres/driver.go index 530c80d..bf71330 100644 --- a/internal/database/postgres/driver.go +++ b/internal/database/postgres/driver.go @@ -6,7 +6,8 @@ import ( "strings" "time" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" "xensql/internal/database" ) @@ -28,12 +29,29 @@ func (d *Driver) Connect(ctx context.Context, cfg database.ConnectionConfig) (da if err := database.ValidateConnectionConfig(cfg); err != nil { return nil, err } - db, err := sql.Open("pgx", buildDSN(cfg)) + // ParseConfig + OpenDB (not sql.Open) so a tunnel can supply DialFunc. The DSN keeps the real + // hostname, so sslmode=verify-full still checks the right certificate. + connCfg, err := pgx.ParseConfig(buildDSN(cfg)) if err != nil { return nil, err } + tunnel, err := database.OpenTunnel(ctx, cfg) + if err != nil { + return nil, err + } + closeTunnel := func() error { + if tunnel == nil { + return nil + } + return tunnel.Close() + } + if tunnel != nil { + connCfg.DialFunc = tunnel.DialContext + } + db := stdlib.OpenDB(*connCfg) db.SetMaxOpenConns(10) if err := database.PingOrClose(ctx, db, 10*time.Second); err != nil { + _ = closeTunnel() return nil, err } schema := cfg.Schema @@ -52,6 +70,7 @@ func (d *Driver) Connect(ctx context.Context, cfg database.ConnectionConfig) (da SetupConn: s.setSearchPath, RegisterKill: s.registerQueryKill, ListCols: s.ListColumns, + OnClose: closeTunnel, } return s, nil } diff --git a/internal/database/session_base.go b/internal/database/session_base.go index 5b2db86..ffca47d 100644 --- a/internal/database/session_base.go +++ b/internal/database/session_base.go @@ -27,11 +27,22 @@ type SessionBase struct { RegisterKill func(ctx context.Context, conn *sql.Conn) error // ListCols is the embedding session's ListColumns, needed here for primary-key discovery. ListCols func(ctx context.Context, schema, table string) ([]ColumnInfo, error) + // OnClose releases resources the pool cannot see (e.g. an SSH tunnel); nil when there are none. + OnClose func() error } func (b *SessionBase) DriverType() DriverType { return b.Driver } -func (b *SessionBase) Close() error { return b.DB.Close() } +// Close drains the pool before OnClose tears the transport down. +func (b *SessionBase) Close() error { + err := b.DB.Close() + if b.OnClose != nil { + if closeErr := b.OnClose(); err == nil { + err = closeErr + } + } + return err +} func (b *SessionBase) Ping(ctx context.Context) error { return b.DB.PingContext(ctx) } diff --git a/internal/database/ssh.go b/internal/database/ssh.go new file mode 100644 index 0000000..bec4131 --- /dev/null +++ b/internal/database/ssh.go @@ -0,0 +1,26 @@ +package database + +import ( + "context" + + "xensql/internal/database/sshtunnel" +) + +// OpenTunnel returns (nil, nil) when the connection dials the database directly. A non-nil Tunnel +// must be closed with the session via SessionBase.OnClose. +func OpenTunnel(ctx context.Context, cfg ConnectionConfig) (*sshtunnel.Tunnel, error) { + if !cfg.SSH.Enabled { + return nil, nil + } + return sshtunnel.Open(ctx, sshtunnel.Config{ + Host: cfg.SSH.Host, + Port: cfg.SSH.Port, + User: cfg.SSH.Username, + Auth: sshtunnel.AuthMethod(cfg.SSH.Auth), + Password: cfg.SSH.Password, + KeyPath: cfg.SSH.KeyPath, + Passphrase: cfg.SSH.Passphrase, + KnownHosts: cfg.SSH.KnownHosts, + IgnoreHostKey: cfg.SSH.IgnoreHostKey, + }) +} diff --git a/internal/database/sshtunnel/tunnel.go b/internal/database/sshtunnel/tunnel.go new file mode 100644 index 0000000..b5a7436 --- /dev/null +++ b/internal/database/sshtunnel/tunnel.go @@ -0,0 +1,305 @@ +// Package sshtunnel dials database connections through an SSH bastion via direct-tcpip channels, +// so there is no local listener to manage. +package sshtunnel + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +type AuthMethod string + +const ( + AuthPassword AuthMethod = "password" + AuthKey AuthMethod = "key" + AuthAgent AuthMethod = "agent" +) + +const ( + DefaultPort = 22 + dialTimeout = 15 * time.Second + keepaliveEvery = 30 * time.Second + keepaliveRequest = "keepalive@openssh.com" +) + +type Config struct { + Host string + Port int + User string + Auth AuthMethod + Password string + KeyPath string + Passphrase string + // KnownHosts overrides the default ~/.ssh/known_hosts. + KnownHosts string + // IgnoreHostKey accepts any bastion key, leaving the hop open to interception. + IgnoreHostKey bool +} + +func (c Config) addr() string { + port := c.Port + if port == 0 { + port = DefaultPort + } + return net.JoinHostPort(c.Host, fmt.Sprint(port)) +} + +type Tunnel struct { + client *ssh.Client + done chan struct{} +} + +// Open establishes the SSH connection; the caller must Close it. +func Open(ctx context.Context, cfg Config) (*Tunnel, error) { + clientCfg, err := clientConfig(cfg) + if err != nil { + return nil, err + } + + dialer := &net.Dialer{Timeout: dialTimeout} + conn, err := dialer.DialContext(ctx, "tcp", cfg.addr()) + if err != nil { + return nil, fmt.Errorf("ssh tunnel: cannot reach bastion %s: %w", cfg.addr(), err) + } + + // NewClientConn takes no context, so bound the handshake with a deadline. + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } else { + _ = conn.SetDeadline(time.Now().Add(dialTimeout)) + } + c, chans, reqs, err := ssh.NewClientConn(conn, cfg.addr(), clientCfg) + if err != nil { + _ = conn.Close() + return nil, describeHandshakeError(cfg, err) + } + _ = conn.SetDeadline(time.Time{}) + + t := &Tunnel{client: ssh.NewClient(c, chans, reqs), done: make(chan struct{})} + go t.keepalive() + return t, nil +} + +// DialContext matches the dialer signature pgx (DialFunc) and go-sql-driver (RegisterDialContext) +// expect. +func (t *Tunnel) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := t.client.DialContext(ctx, network, addr) + if err != nil { + return nil, fmt.Errorf("ssh tunnel: cannot reach %s from the bastion: %w", addr, err) + } + return tunnelConn{Conn: conn}, nil +} + +// tunnelConn absorbs the SetDeadline calls the drivers make mid-query, which a raw SSH channel +// rejects outright. Future deadlines go unenforced (a channel read cannot be interrupted); an +// elapsed one is the drivers' abort signal, so it closes. Query cancellation is server-side +// (pg_cancel_backend / KILL QUERY) and unaffected. +type tunnelConn struct{ net.Conn } + +func (c tunnelConn) SetDeadline(t time.Time) error { return c.abortIfElapsed(t) } +func (c tunnelConn) SetReadDeadline(t time.Time) error { return c.abortIfElapsed(t) } +func (c tunnelConn) SetWriteDeadline(t time.Time) error { return c.abortIfElapsed(t) } + +func (c tunnelConn) abortIfElapsed(t time.Time) error { + if !t.IsZero() && !t.After(time.Now()) { + return c.Conn.Close() + } + return nil +} + +func (t *Tunnel) Close() error { + select { + case <-t.done: // already closed + default: + close(t.done) + } + return t.client.Close() +} + +// keepalive stops idle bastions and firewalls from dropping a quiet tunnel. +func (t *Tunnel) keepalive() { + ticker := time.NewTicker(keepaliveEvery) + defer ticker.Stop() + for { + select { + case <-t.done: + return + case <-ticker.C: + if _, _, err := t.client.SendRequest(keepaliveRequest, true, nil); err != nil { + return // gone; pending dials surface the error + } + } + } +} + +func clientConfig(cfg Config) (*ssh.ClientConfig, error) { + if strings.TrimSpace(cfg.Host) == "" { + return nil, errors.New("ssh tunnel: bastion host is required") + } + if strings.TrimSpace(cfg.User) == "" { + return nil, errors.New("ssh tunnel: bastion username is required") + } + auth, err := authMethod(cfg) + if err != nil { + return nil, err + } + hostKey, err := hostKeyCallback(cfg) + if err != nil { + return nil, err + } + return &ssh.ClientConfig{ + User: cfg.User, + Auth: []ssh.AuthMethod{auth}, + HostKeyCallback: hostKey, + Timeout: dialTimeout, + }, nil +} + +func authMethod(cfg Config) (ssh.AuthMethod, error) { + switch cfg.Auth { + case AuthPassword: + return ssh.Password(cfg.Password), nil + case AuthAgent: + return agentAuth() + case AuthKey, "": + return keyAuth(cfg) + default: + return nil, fmt.Errorf("ssh tunnel: unknown authentication method %q", cfg.Auth) + } +} + +func keyAuth(cfg Config) (ssh.AuthMethod, error) { + path, err := ExpandPath(cfg.KeyPath) + if err != nil { + return nil, err + } + if path == "" { + return nil, errors.New("ssh tunnel: private key file is required") + } + pem, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ssh tunnel: cannot read private key %s: %w", path, err) + } + signer, err := parsePrivateKey(pem, cfg.Passphrase) + if err != nil { + return nil, err + } + return ssh.PublicKeys(signer), nil +} + +func parsePrivateKey(pem []byte, passphrase string) (ssh.Signer, error) { + if passphrase != "" { + signer, err := ssh.ParsePrivateKeyWithPassphrase(pem, []byte(passphrase)) + if err != nil { + return nil, fmt.Errorf("ssh tunnel: cannot decrypt private key (wrong passphrase?): %w", err) + } + return signer, nil + } + signer, err := ssh.ParsePrivateKey(pem) + if err != nil { + var needsPassphrase *ssh.PassphraseMissingError + if errors.As(err, &needsPassphrase) { + return nil, errors.New("ssh tunnel: private key is encrypted - enter its passphrase") + } + return nil, fmt.Errorf("ssh tunnel: cannot parse private key: %w", err) + } + return signer, nil +} + +func agentAuth() (ssh.AuthMethod, error) { + sock := os.Getenv("SSH_AUTH_SOCK") + if sock == "" { + return nil, errors.New("ssh tunnel: no ssh-agent found (SSH_AUTH_SOCK is not set)") + } + conn, err := net.Dial("unix", sock) + if err != nil { + return nil, fmt.Errorf("ssh tunnel: cannot reach ssh-agent: %w", err) + } + return ssh.PublicKeysCallback(agent.NewClient(conn).Signers), nil +} + +func hostKeyCallback(cfg Config) (ssh.HostKeyCallback, error) { + if cfg.IgnoreHostKey { + return ssh.InsecureIgnoreHostKey(), nil //nolint:gosec // user-selected escape hatch, off by default + } + path, err := knownHostsPath(cfg) + if err != nil { + return nil, err + } + callback, err := knownhosts.New(path) + if err != nil { + return nil, fmt.Errorf("ssh tunnel: cannot read known_hosts %s: %w", path, err) + } + return callback, nil +} + +// defaultKnownHostsPath uses the host OS path style, for messages where the home dir is unknown. +func defaultKnownHostsPath() string { + return filepath.Join("~", ".ssh", "known_hosts") +} + +func knownHostsPath(cfg Config) (string, error) { + if strings.TrimSpace(cfg.KnownHosts) != "" { + return ExpandPath(cfg.KnownHosts) + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("ssh tunnel: cannot locate the home directory for known_hosts: %w", err) + } + path := filepath.Join(home, ".ssh", "known_hosts") + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf( + "ssh tunnel: no known_hosts file at %s - add the bastion with "+ + "`ssh-keyscan -H %s >> %s`, or enable \"Skip host key check\"", path, cfg.Host, path) + } + return path, nil +} + +// describeHandshakeError names the fix for the two failures users actually hit. +func describeHandshakeError(cfg Config, err error) error { + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) { + if len(keyErr.Want) == 0 { + path, pathErr := knownHostsPath(cfg) + if pathErr != nil { + path = defaultKnownHostsPath() + } + return fmt.Errorf( + "ssh tunnel: bastion %s is not in known_hosts - add it with "+ + "`ssh-keyscan -H %s >> %s`, or enable \"Skip host key check\"", cfg.Host, cfg.Host, path) + } + return fmt.Errorf( + "ssh tunnel: host key for %s does not match known_hosts - the bastion key changed, "+ + "or the connection is being intercepted: %w", cfg.Host, err) + } + if strings.Contains(err.Error(), "unable to authenticate") { + return fmt.Errorf("ssh tunnel: bastion rejected the credentials for user %q: %w", cfg.User, err) + } + return fmt.Errorf("ssh tunnel: handshake with %s failed: %w", cfg.addr(), err) +} + +// ExpandPath resolves a leading ~ so paths can be stored the way users type them. +func ExpandPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, `~\`) { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("ssh tunnel: cannot expand %q: %w", path, err) + } + if path == "~" { + return home, nil + } + return filepath.Join(home, path[2:]), nil +} diff --git a/internal/database/sshtunnel/tunnel_test.go b/internal/database/sshtunnel/tunnel_test.go new file mode 100644 index 0000000..ff261d6 --- /dev/null +++ b/internal/database/sshtunnel/tunnel_test.go @@ -0,0 +1,493 @@ +package sshtunnel + +import ( + "context" + "crypto/ed25519" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +// directTCPIP is the payload of a direct-tcpip channel open request (RFC 4254 s7.2). +type directTCPIP struct { + DestHost string + DestPort uint32 + SrcHost string + SrcPort uint32 +} + +type serverAuth struct { + password string + publicKey ssh.PublicKey +} + +// startBastion runs an in-process SSH server forwarding direct-tcpip, the same path a real bastion +// takes. +func startBastion(t *testing.T, auth serverAuth) (addr string, hostKey ssh.PublicKey) { + t.Helper() + + _, hostPriv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + hostSigner, err := ssh.NewSignerFromKey(hostPriv) + if err != nil { + t.Fatalf("host signer: %v", err) + } + + srvCfg := &ssh.ServerConfig{} + if auth.password != "" { + srvCfg.PasswordCallback = func(_ ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if string(pass) != auth.password { + return nil, errors.New("bad password") + } + return nil, nil + } + } + if auth.publicKey != nil { + srvCfg.PublicKeyCallback = func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if !bytesEqual(key.Marshal(), auth.publicKey.Marshal()) { + return nil, errors.New("unknown key") + } + return nil, nil + } + } + srvCfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveBastionConn(conn, srvCfg) + } + }() + + return ln.Addr().String(), hostSigner.PublicKey() +} + +func serveBastionConn(conn net.Conn, cfg *ssh.ServerConfig) { + sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) + if err != nil { + _ = conn.Close() + return + } + defer sshConn.Close() + go ssh.DiscardRequests(reqs) // keepalives + for newCh := range chans { + if newCh.ChannelType() != "direct-tcpip" { + _ = newCh.Reject(ssh.UnknownChannelType, "only direct-tcpip") + continue + } + var payload directTCPIP + if err := ssh.Unmarshal(newCh.ExtraData(), &payload); err != nil { + _ = newCh.Reject(ssh.ConnectionFailed, "bad payload") + continue + } + target, err := net.Dial("tcp", net.JoinHostPort(payload.DestHost, fmt.Sprint(payload.DestPort))) + if err != nil { + _ = newCh.Reject(ssh.ConnectionFailed, err.Error()) + continue + } + ch, chReqs, err := newCh.Accept() + if err != nil { + _ = target.Close() + continue + } + go ssh.DiscardRequests(chReqs) + go func() { + defer ch.Close() + defer target.Close() + go func() { _, _ = io.Copy(target, ch) }() + _, _ = io.Copy(ch, target) + }() + } +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// startEchoServer stands in for the database. +func startEchoServer(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _, _ = io.Copy(conn, conn) + }() + } + }() + return ln.Addr().String() +} + +func writeKnownHosts(t *testing.T, addr string, key ssh.PublicKey) string { + t.Helper() + path := filepath.Join(t.TempDir(), "known_hosts") + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + // Non-standard ports are recorded as [host]:port. + entry := fmt.Sprintf("[%s]:%s %s\n", host, port, string(ssh.MarshalAuthorizedKey(key))) + if err := os.WriteFile(path, []byte(entry), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + return path +} + +func splitAddr(t *testing.T, addr string) (string, int) { + t.Helper() + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split addr: %v", err) + } + var port int + if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { + t.Fatalf("parse port: %v", err) + } + return host, port +} + +func TestTunnelForwardsToTarget(t *testing.T) { + bastion, hostKey := startBastion(t, serverAuth{password: "hunter2"}) + echo := startEchoServer(t) + host, port := splitAddr(t, bastion) + + tunnel, err := Open(context.Background(), Config{ + Host: host, + Port: port, + User: "dev", + Auth: AuthPassword, + Password: "hunter2", + KnownHosts: writeKnownHosts(t, bastion, hostKey), + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer tunnel.Close() + + conn, err := tunnel.DialContext(context.Background(), "tcp", echo) + if err != nil { + t.Fatalf("DialContext: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("SELECT 1")); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, 8) + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatalf("set deadline: %v", err) + } + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "SELECT 1" { + t.Fatalf("got %q through the tunnel, want %q", buf, "SELECT 1") + } +} + +// A raw SSH channel rejects SetDeadline, which would fail the drivers' queries. +func TestConnAcceptsDeadlinesTheDriversSet(t *testing.T) { + bastion, hostKey := startBastion(t, serverAuth{password: "pw"}) + echo := startEchoServer(t) + host, port := splitAddr(t, bastion) + + tunnel, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthPassword, Password: "pw", + KnownHosts: writeKnownHosts(t, bastion, hostKey), + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer tunnel.Close() + + conn, err := tunnel.DialContext(context.Background(), "tcp", echo) + if err != nil { + t.Fatalf("DialContext: %v", err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(time.Minute)); err != nil { + t.Fatalf("future deadline rejected: %v", err) + } + if err := conn.SetReadDeadline(time.Now().Add(time.Minute)); err != nil { + t.Fatalf("future read deadline rejected: %v", err) + } + if err := conn.SetWriteDeadline(time.Now().Add(time.Minute)); err != nil { + t.Fatalf("future write deadline rejected: %v", err) + } + if err := conn.SetDeadline(time.Time{}); err != nil { + t.Fatalf("clearing the deadline failed: %v", err) + } + if _, err := conn.Write([]byte("ok")); err != nil { + t.Fatalf("connection unusable after setting deadlines: %v", err) + } + + // An elapsed deadline is the drivers' abort signal. + if err := conn.SetDeadline(time.Now().Add(-time.Second)); err != nil { + t.Fatalf("abort deadline: %v", err) + } + if _, err := conn.Write([]byte("gone")); err == nil { + t.Fatal("expected the connection to be closed by the elapsed deadline") + } +} + +func TestTunnelDialErrorNamesTheTarget(t *testing.T) { + bastion, hostKey := startBastion(t, serverAuth{password: "pw"}) + host, port := splitAddr(t, bastion) + + tunnel, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthPassword, Password: "pw", + KnownHosts: writeKnownHosts(t, bastion, hostKey), + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer tunnel.Close() + + // Port 1 refuses; the error must name the hop that failed. + _, err = tunnel.DialContext(context.Background(), "tcp", "127.0.0.1:1") + if err == nil { + t.Fatal("expected a dial error") + } + if !strings.Contains(err.Error(), "from the bastion") { + t.Fatalf("error does not identify the failing hop: %v", err) + } +} + +func TestPublicKeyAuth(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate key: %v", err) + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("public key: %v", err) + } + block, err := ssh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyPath := filepath.Join(t.TempDir(), "id_ed25519") + if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + bastion, hostKey := startBastion(t, serverAuth{publicKey: sshPub}) + host, port := splitAddr(t, bastion) + + tunnel, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthKey, KeyPath: keyPath, + KnownHosts: writeKnownHosts(t, bastion, hostKey), + }) + if err != nil { + t.Fatalf("Open with key auth: %v", err) + } + _ = tunnel.Close() +} + +func TestEncryptedKeyRequiresPassphrase(t *testing.T) { + _, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate key: %v", err) + } + block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("s3cret")) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + encrypted := pem.EncodeToMemory(block) + + if _, err := parsePrivateKey(encrypted, ""); err == nil { + t.Fatal("expected an error for an encrypted key with no passphrase") + } else if !strings.Contains(err.Error(), "passphrase") { + t.Fatalf("error should mention the passphrase, got: %v", err) + } + + if _, err := parsePrivateKey(encrypted, "wrong"); err == nil { + t.Fatal("expected an error for the wrong passphrase") + } + + if _, err := parsePrivateKey(encrypted, "s3cret"); err != nil { + t.Fatalf("correct passphrase should parse: %v", err) + } +} + +func TestUnknownHostKeyIsRejectedWithGuidance(t *testing.T) { + bastion, _ := startBastion(t, serverAuth{password: "pw"}) + host, port := splitAddr(t, bastion) + + // known_hosts exists but has no entry for this bastion. + empty := filepath.Join(t.TempDir(), "known_hosts") + if err := os.WriteFile(empty, []byte(""), 0o600); err != nil { + t.Fatalf("write known_hosts: %v", err) + } + + _, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthPassword, Password: "pw", + KnownHosts: empty, + }) + if err == nil { + t.Fatal("expected the unknown host key to be rejected") + } + if !strings.Contains(err.Error(), "ssh-keyscan") { + t.Fatalf("error should tell the user how to fix it, got: %v", err) + } +} + +func TestMismatchedHostKeyIsRejected(t *testing.T) { + bastion, _ := startBastion(t, serverAuth{password: "pw"}) + other, otherKey := startBastion(t, serverAuth{password: "pw"}) + _ = other + host, port := splitAddr(t, bastion) + + // Record a different server's key against this bastion's address. + wrong := writeKnownHosts(t, bastion, otherKey) + + _, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthPassword, Password: "pw", + KnownHosts: wrong, + }) + if err == nil { + t.Fatal("expected a host key mismatch to be rejected") + } + if !strings.Contains(err.Error(), "does not match known_hosts") { + t.Fatalf("error should flag the mismatch, got: %v", err) + } +} + +func TestIgnoreHostKeySkipsVerification(t *testing.T) { + bastion, _ := startBastion(t, serverAuth{password: "pw"}) + host, port := splitAddr(t, bastion) + + tunnel, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthPassword, Password: "pw", + IgnoreHostKey: true, + }) + if err != nil { + t.Fatalf("Open with IgnoreHostKey: %v", err) + } + _ = tunnel.Close() +} + +func TestBadCredentialsAreReported(t *testing.T) { + bastion, hostKey := startBastion(t, serverAuth{password: "right"}) + host, port := splitAddr(t, bastion) + + _, err := Open(context.Background(), Config{ + Host: host, Port: port, User: "dev", + Auth: AuthPassword, Password: "wrong", + KnownHosts: writeKnownHosts(t, bastion, hostKey), + }) + if err == nil { + t.Fatal("expected authentication to fail") + } + if !strings.Contains(err.Error(), "rejected the credentials") { + t.Fatalf("error should name the cause, got: %v", err) + } +} + +func TestClientConfigValidation(t *testing.T) { + tests := []struct { + name string + cfg Config + want string + }{ + {"no host", Config{User: "dev", IgnoreHostKey: true}, "bastion host is required"}, + {"no user", Config{Host: "h", IgnoreHostKey: true}, "bastion username is required"}, + {"no key path", Config{Host: "h", User: "d", Auth: AuthKey, IgnoreHostKey: true}, "private key file is required"}, + {"bad method", Config{Host: "h", User: "d", Auth: "carrier-pigeon", IgnoreHostKey: true}, "unknown authentication method"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := clientConfig(tc.cfg) + if err == nil { + t.Fatalf("expected an error containing %q", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("got %v, want it to contain %q", err, tc.want) + } + }) + } +} + +func TestOpenFailsFastOnUnreachableBastion(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + // Port 1 on loopback refuses immediately. + _, err := Open(ctx, Config{ + Host: "127.0.0.1", Port: 1, User: "dev", + Auth: AuthPassword, Password: "pw", IgnoreHostKey: true, + }) + if err == nil { + t.Fatal("expected an error reaching the bastion") + } + if !strings.Contains(err.Error(), "cannot reach bastion") { + t.Fatalf("got %v, want it to name the bastion hop", err) + } +} + +func TestExpandPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory") + } + got, err := ExpandPath("~/.ssh/id_rsa") + if err != nil { + t.Fatalf("ExpandPath: %v", err) + } + if want := filepath.Join(home, ".ssh", "id_rsa"); got != want { + t.Fatalf("got %q, want %q", got, want) + } + // Bare paths and embedded tildes pass through untouched. + for _, p := range []string{"/abs/id_rsa", "relative/id_rsa", "/tmp/we~ird"} { + got, err := ExpandPath(p) + if err != nil { + t.Fatalf("ExpandPath(%q): %v", p, err) + } + if got != p { + t.Fatalf("ExpandPath(%q) = %q, want it unchanged", p, got) + } + } +} diff --git a/internal/database/types.go b/internal/database/types.go index f26e474..5cc1a12 100644 --- a/internal/database/types.go +++ b/internal/database/types.go @@ -30,6 +30,32 @@ type ConnectionConfig struct { // Statement-level gate only - does not block side-effecting functions inside SELECT (e.g. pg_terminate_backend). // Pair with a restricted DB role for hard isolation. ReadOnly bool `json:"readOnly,omitempty"` + + // SSH tunnel (PostgreSQL / MySQL / MariaDB). Host/Port above are resolved from the bastion. + SSH SSHConfig `json:"ssh,omitempty"` +} + +type SSHAuthMethod string + +const ( + SSHAuthPassword SSHAuthMethod = "password" + SSHAuthKey SSHAuthMethod = "key" + SSHAuthAgent SSHAuthMethod = "agent" +) + +type SSHConfig struct { + Enabled bool `json:"enabled,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Auth SSHAuthMethod `json:"auth,omitempty"` + Password string `json:"password,omitempty"` + KeyPath string `json:"keyPath,omitempty"` + Passphrase string `json:"passphrase,omitempty"` + // KnownHosts overrides the default ~/.ssh/known_hosts. + KnownHosts string `json:"knownHosts,omitempty"` + // IgnoreHostKey accepts any bastion key, leaving the hop open to interception. + IgnoreHostKey bool `json:"ignoreHostKey,omitempty"` } type ColumnInfo struct {