Skip to content

Commit a69d39d

Browse files
committed
Add SSH tunnel support for database connections
- Add SSH tab in connection dialog with host, port, username, auth type options - Support both password and key-based SSH authentication - Implement SSH tunnel creation in adapters.py using sshtunnel library - Add SSH CLI options for connection create command - Add SSH integration tests with Docker-based SSH server - Pin paramiko<4.0.0 to avoid DSSKey compatibility issue - Update CI workflow with SSH tunnel test job
1 parent 8920087 commit a69d39d

File tree

11 files changed

+964
-22
lines changed

11 files changed

+964
-22
lines changed

.github/workflows/ci.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,86 @@ jobs:
365365
COCKROACHDB_DATABASE: test_sqlit
366366
run: |
367367
pytest tests/test_cockroachdb.py -v --timeout=120
368+
369+
test-ssh:
370+
runs-on: ubuntu-latest
371+
needs: build
372+
373+
steps:
374+
- uses: actions/checkout@v4
375+
376+
- name: Set up Python 3.12
377+
uses: actions/setup-python@v5
378+
with:
379+
python-version: "3.12"
380+
381+
- name: Install dependencies
382+
run: |
383+
python -m pip install --upgrade pip
384+
pip install -e ".[test]"
385+
pip install psycopg2-binary
386+
387+
- name: Create Docker network
388+
run: docker network create ssh-test-net
389+
390+
- name: Start PostgreSQL
391+
run: |
392+
docker run -d --name postgres --network ssh-test-net \
393+
-e POSTGRES_USER=testuser \
394+
-e POSTGRES_PASSWORD=TestPassword123! \
395+
-e POSTGRES_DB=test_sqlit \
396+
-p 5433:5432 \
397+
postgres:16-alpine
398+
# Wait for PostgreSQL to be ready
399+
for i in {1..30}; do
400+
if docker exec postgres pg_isready -U testuser -d test_sqlit > /dev/null 2>&1; then
401+
echo "PostgreSQL is ready"
402+
break
403+
fi
404+
echo "Waiting for PostgreSQL... ($i/30)"
405+
sleep 2
406+
done
407+
408+
- name: Start SSH server
409+
run: |
410+
docker run -d --name sshserver --network ssh-test-net \
411+
-e PUID=1000 \
412+
-e PGID=1000 \
413+
-e USER_NAME=testuser \
414+
-e USER_PASSWORD=testpass \
415+
-e PASSWORD_ACCESS=true \
416+
-e DOCKER_MODS=linuxserver/mods:openssh-server-ssh-tunnel \
417+
-p 2222:2222 \
418+
lscr.io/linuxserver/openssh-server:latest
419+
# Wait for SSH to be ready
420+
for i in {1..30}; do
421+
if nc -z localhost 2222 2>/dev/null; then
422+
echo "SSH server is ready"
423+
break
424+
fi
425+
echo "Waiting for SSH server... ($i/30)"
426+
sleep 2
427+
done
428+
429+
- name: Run SSH tunnel integration tests
430+
env:
431+
SSH_HOST: localhost
432+
SSH_PORT: 2222
433+
SSH_USER: testuser
434+
SSH_PASSWORD: testpass
435+
SSH_REMOTE_DB_HOST: postgres
436+
SSH_REMOTE_DB_PORT: 5432
437+
SSH_DIRECT_PG_HOST: localhost
438+
SSH_DIRECT_PG_PORT: 5433
439+
POSTGRES_USER: testuser
440+
POSTGRES_PASSWORD: TestPassword123!
441+
POSTGRES_DATABASE: test_sqlit
442+
run: |
443+
pytest tests/test_ssh.py -v --timeout=120
444+
445+
- name: Cleanup
446+
if: always()
447+
run: |
448+
docker stop sshserver postgres || true
449+
docker rm sshserver postgres || true
450+
docker network rm ssh-test-net || true

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sqlit-tui"
7-
version = "0.3.1"
7+
version = "0.3.5"
88
description = "A terminal UI for SQL Server, PostgreSQL, MySQL, SQLite, and Oracle"
99
readme = "README.md"
1010
license = "MIT"
@@ -30,6 +30,8 @@ dependencies = [
3030
"textual[syntax]>=0.50.0",
3131
"pyodbc>=5.0.0",
3232
"pyperclip>=1.8.2",
33+
"sshtunnel>=0.4.0",
34+
"paramiko>=2.0.0,<4.0.0", # sshtunnel requires paramiko<4.0.0 (DSSKey removed in 4.0)
3335
]
3436

3537
[project.optional-dependencies]

sqlit/adapters.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from __future__ import annotations
44

5+
import os
56
import sqlite3
67
from abc import ABC, abstractmethod
78
from dataclasses import dataclass
9+
from pathlib import Path
810
from typing import TYPE_CHECKING, Any
911

1012
from .fields import (
@@ -20,6 +22,54 @@
2022
from .config import ConnectionConfig
2123

2224

25+
def create_ssh_tunnel(config: "ConnectionConfig") -> tuple[Any, str, int]:
26+
"""Create an SSH tunnel for the connection if SSH is enabled.
27+
28+
Returns:
29+
Tuple of (tunnel_object, local_host, local_port) if SSH enabled,
30+
or (None, original_server, original_port) if SSH not enabled.
31+
"""
32+
if not config.ssh_enabled:
33+
port = int(config.port) if config.port else 0
34+
return None, config.server, port
35+
36+
from sshtunnel import SSHTunnelForwarder
37+
38+
# Parse remote database host and port
39+
remote_host = config.server
40+
remote_port = int(config.port) if config.port else 0
41+
42+
# SSH connection settings
43+
ssh_host = config.ssh_host
44+
ssh_port = int(config.ssh_port) if config.ssh_port else 22
45+
ssh_username = config.ssh_username
46+
47+
# Build SSH auth kwargs
48+
ssh_kwargs: dict[str, Any] = {
49+
"ssh_username": ssh_username,
50+
}
51+
52+
if config.ssh_auth_type == "key":
53+
# Expand ~ in path
54+
key_path = os.path.expanduser(config.ssh_key_path)
55+
if Path(key_path).exists():
56+
ssh_kwargs["ssh_pkey"] = key_path
57+
else:
58+
raise ValueError(f"SSH key file not found: {key_path}")
59+
else:
60+
ssh_kwargs["ssh_password"] = config.ssh_password
61+
62+
# Create tunnel
63+
tunnel = SSHTunnelForwarder(
64+
(ssh_host, ssh_port),
65+
remote_bind_address=(remote_host, remote_port),
66+
**ssh_kwargs,
67+
)
68+
tunnel.start()
69+
70+
return tunnel, "127.0.0.1", tunnel.local_bind_port
71+
72+
2373
@dataclass
2474
class ColumnInfo:
2575
"""Information about a database column."""
@@ -316,12 +366,16 @@ def get_connection_fields(self) -> list[FieldGroup]:
316366
placeholder="",
317367
visible_when=lambda v: v.get("auth_type") in auth_needs_username,
318368
required=True,
369+
row_group="credentials",
370+
width="flex",
319371
),
320372
FieldDefinition(
321373
name="password",
322374
label="Password",
323375
field_type=FieldType.PASSWORD,
324376
visible_when=lambda v: v.get("auth_type") in auth_needs_password,
377+
row_group="credentials",
378+
width="flex",
325379
),
326380
],
327381
),

sqlit/app.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from textual.containers import Container, Horizontal, Vertical
1717
from textual.widgets import DataTable, Static, TextArea, Tree
1818

19-
from .adapters import DatabaseAdapter, get_adapter
19+
from .adapters import DatabaseAdapter, create_ssh_tunnel, get_adapter
2020
from .config import (
2121
ConnectionConfig,
2222
load_connections,
@@ -197,6 +197,7 @@ def __init__(self):
197197
self.current_connection: Any | None = None
198198
self.current_config: ConnectionConfig | None = None
199199
self.current_adapter: DatabaseAdapter | None = None
200+
self.current_ssh_tunnel: Any | None = None
200201
self.vim_mode: VimMode = VimMode.NORMAL
201202
self._expanded_paths: set[str] = set()
202203
self._schema_cache: dict = {
@@ -1228,27 +1229,55 @@ def handle_connection_result(self, result: tuple | None) -> None:
12281229

12291230
def connect_to_server(self, config: ConnectionConfig) -> None:
12301231
"""Connect to a database."""
1232+
from dataclasses import replace
1233+
12311234
# Check for pyodbc only if it's a SQL Server connection
12321235
if config.db_type == "mssql" and not PYODBC_AVAILABLE:
12331236
self.notify("pyodbc not installed. Run: pip install pyodbc", severity="error")
12341237
return
12351238

12361239
try:
1240+
# Close any existing SSH tunnel
1241+
if self.current_ssh_tunnel:
1242+
try:
1243+
self.current_ssh_tunnel.stop()
1244+
except Exception:
1245+
pass
1246+
self.current_ssh_tunnel = None
1247+
1248+
# Create SSH tunnel if enabled
1249+
tunnel, host, port = create_ssh_tunnel(config)
1250+
self.current_ssh_tunnel = tunnel
1251+
1252+
# If SSH tunnel was created, use the tunnel's local address
1253+
if tunnel:
1254+
connect_config = replace(config, server=host, port=str(port))
1255+
else:
1256+
connect_config = config
1257+
12371258
adapter = get_adapter(config.db_type)
1238-
self.current_connection = adapter.connect(config)
1239-
self.current_config = config
1259+
self.current_connection = adapter.connect(connect_config)
1260+
self.current_config = config # Store original config (not tunneled)
12401261
self.current_adapter = adapter
12411262
self._set_connection_health(config.name, True)
12421263

12431264
status = self.query_one("#status-bar", Static)
12441265
display_info = config.get_display_info()
1245-
status.update(f"[#90EE90]Connected to {config.name}[/] ({display_info})")
1266+
ssh_indicator = " [SSH]" if tunnel else ""
1267+
status.update(f"[#90EE90]Connected to {config.name}[/] ({display_info}){ssh_indicator}")
12461268

12471269
self.refresh_tree()
12481270
self._load_schema_cache()
12491271
self.notify(f"Connected to {config.name}")
12501272

12511273
except Exception as e:
1274+
# Clean up SSH tunnel on failure
1275+
if self.current_ssh_tunnel:
1276+
try:
1277+
self.current_ssh_tunnel.stop()
1278+
except Exception:
1279+
pass
1280+
self.current_ssh_tunnel = None
12521281
self._set_connection_health(config.name, False)
12531282
self.refresh_tree()
12541283
self.notify(f"Connection failed: {e}", severity="error")
@@ -1336,6 +1365,14 @@ def _disconnect_silent(self) -> None:
13361365
self.current_config = None
13371366
self.current_adapter = None
13381367

1368+
# Close SSH tunnel if active
1369+
if self.current_ssh_tunnel:
1370+
try:
1371+
self.current_ssh_tunnel.stop()
1372+
except Exception:
1373+
pass
1374+
self.current_ssh_tunnel = None
1375+
13391376
def action_disconnect(self) -> None:
13401377
"""Disconnect from current database."""
13411378
if self.current_connection:

sqlit/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ def main() -> int:
5454
)
5555
# SQLite options
5656
create_parser.add_argument("--file-path", help="Database file path (SQLite only)")
57+
# SSH tunnel options
58+
create_parser.add_argument("--ssh-enabled", action="store_true", help="Enable SSH tunnel")
59+
create_parser.add_argument("--ssh-host", help="SSH server hostname")
60+
create_parser.add_argument("--ssh-port", default="22", help="SSH server port (default: 22)")
61+
create_parser.add_argument("--ssh-username", help="SSH username")
62+
create_parser.add_argument("--ssh-auth-type", default="key", choices=["key", "password"], help="SSH auth type")
63+
create_parser.add_argument("--ssh-key-path", help="SSH private key path")
64+
create_parser.add_argument("--ssh-password", help="SSH password")
5765

5866
# connection edit
5967
edit_parser = conn_subparsers.add_parser("edit", help="Edit an existing connection")

sqlit/commands.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66

7-
from .adapters import get_adapter
7+
from .adapters import create_ssh_tunnel, get_adapter
88
from .config import (
99
AUTH_TYPE_LABELS,
1010
AuthType,
@@ -97,6 +97,13 @@ def cmd_connection_create(args) -> int:
9797
database=args.database or "",
9898
username=args.username or "",
9999
password=args.password or "",
100+
ssh_enabled=getattr(args, "ssh_enabled", False) or False,
101+
ssh_host=getattr(args, "ssh_host", "") or "",
102+
ssh_port=getattr(args, "ssh_port", "22") or "22",
103+
ssh_username=getattr(args, "ssh_username", "") or "",
104+
ssh_auth_type=getattr(args, "ssh_auth_type", "key") or "key",
105+
ssh_key_path=getattr(args, "ssh_key_path", "") or "",
106+
ssh_password=getattr(args, "ssh_password", "") or "",
100107
)
101108
else:
102109
# SQL Server connection (mssql)
@@ -122,6 +129,13 @@ def cmd_connection_create(args) -> int:
122129
password=args.password or "",
123130
auth_type=auth_type.value,
124131
trusted_connection=(auth_type == AuthType.WINDOWS),
132+
ssh_enabled=getattr(args, "ssh_enabled", False) or False,
133+
ssh_host=getattr(args, "ssh_host", "") or "",
134+
ssh_port=getattr(args, "ssh_port", "22") or "22",
135+
ssh_username=getattr(args, "ssh_username", "") or "",
136+
ssh_auth_type=getattr(args, "ssh_auth_type", "key") or "key",
137+
ssh_key_path=getattr(args, "ssh_key_path", "") or "",
138+
ssh_password=getattr(args, "ssh_password", "") or "",
125139
)
126140

127141
connections.append(config)
@@ -237,9 +251,19 @@ def cmd_query(args) -> int:
237251
print("Error: Either --query or --file must be provided.")
238252
return 1
239253

254+
tunnel = None
240255
try:
256+
from dataclasses import replace
257+
258+
# Create SSH tunnel if enabled
259+
tunnel, host, port = create_ssh_tunnel(config)
260+
if tunnel:
261+
connect_config = replace(config, server=host, port=str(port))
262+
else:
263+
connect_config = config
264+
241265
adapter = get_adapter(config.db_type)
242-
db_conn = adapter.connect(config)
266+
db_conn = adapter.connect(connect_config)
243267

244268
# Detect query type to avoid executing non-SELECT statements twice
245269
query_type = query.strip().upper().split()[0] if query.strip() else ""
@@ -294,11 +318,17 @@ def cmd_query(args) -> int:
294318
print(f"Query executed successfully. Rows affected: {affected}")
295319

296320
db_conn.close()
321+
if tunnel:
322+
tunnel.stop()
297323
return 0
298324

299325
except ImportError as e:
300326
print(f"Error: Required module not installed: {e}")
327+
if tunnel:
328+
tunnel.stop()
301329
return 1
302330
except Exception as e:
303331
print(f"Error: {e}")
332+
if tunnel:
333+
tunnel.stop()
304334
return 1

sqlit/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ class ConnectionConfig:
7070
trusted_connection: bool = False # Legacy field for backwards compatibility
7171
# SQLite specific fields
7272
file_path: str = ""
73+
# SSH tunnel fields
74+
ssh_enabled: bool = False
75+
ssh_host: str = ""
76+
ssh_port: str = "22"
77+
ssh_username: str = ""
78+
ssh_auth_type: str = "key" # "key" or "password"
79+
ssh_password: str = ""
80+
ssh_key_path: str = ""
7381

7482
def __post_init__(self):
7583
"""Handle backwards compatibility with old configs."""

0 commit comments

Comments
 (0)