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
Read & write + read-only mode
SSL: require / verify-full
+ SSH tunnel to a bastion
Powered by pgx
@@ -138,6 +139,7 @@ Supported databases
Read & write + read-only mode
TLS transport
+ SSH tunnel to a bastion
Powered by go-sql-driver
@@ -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('common.browse')}
@@ -259,6 +288,133 @@ export function ConnectionDialog({ connection, onClose, onSaved }: Props) {
{form.driver === 'mysql' && {t('connection.defaultSchemaMysqlHint')}
}
+
+
+
+ updateSSH({ enabled: e.target.checked })}
+ />
+ {t('connection.sshTunnel')}
+
+
{t('connection.sshTunnelHint')}
+
+ {ssh.enabled && (
+ <>
+
+
+
+ {t('connection.sshUsername')}
+ updateSSH({ username: e.target.value })}
+ />
+
+
+ {t('connection.sshAuth')}
+ updateSSH({ auth: e.target.value as SSHAuthMethod })}
+ >
+ {t('connection.sshAuthKey')}
+ {t('connection.sshAuthPassword')}
+ {t('connection.sshAuthAgent')}
+
+
+
+ {sshAuth === 'key' && (
+ <>
+
+
{t('connection.sshKeyPath')}
+
+ updateSSH({ keyPath: e.target.value })}
+ placeholder={paths.sshKey}
+ />
+
+ {t('common.browse')}
+
+
+
+
+ {t('connection.sshPassphrase')}
+ updateSSH({ passphrase: e.target.value })}
+ placeholder={t('connection.sshPassphrasePlaceholder')}
+ />
+
+ >
+ )}
+ {sshAuth === 'password' && (
+
+ {t('connection.sshPassword')}
+ updateSSH({ password: e.target.value })}
+ />
+
+ )}
+ {sshAuth === 'agent' &&
{t('connection.sshAuthAgentHint')}
}
+
+
+ updateSSH({ ignoreHostKey: e.target.checked })}
+ />
+ {t('connection.sshIgnoreHostKey')}
+
+
{t('connection.sshIgnoreHostKeyHint')}
+
+ {!ssh.ignoreHostKey && (
+
+
{t('connection.sshKnownHosts')}
+
+ updateSSH({ knownHosts: e.target.value })}
+ placeholder={paths.sshKnownHosts}
+ />
+
+ {t('common.browse')}
+
+
+
{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 {