Skip to main content
Zequel supports SSH tunneling to securely connect to databases that are not directly accessible, such as databases behind firewalls or in private networks.

How SSH Tunneling Works

SSH tunneling creates an encrypted connection to a bastion/jump host, which then forwards traffic to your database server:
Zequel → SSH Tunnel → Bastion Host → Database Server
1

SSH Connection

Zequel connects to the bastion host using SSH credentials
2

Port Forwarding

The SSH client creates a local port that forwards to the remote database
3

Database Connection

Zequel connects to localhost:<local-port> which tunnels to the database

Configuration

SSH Config Interface

interface SSHConfig {
  enabled: boolean
  host: string                              // SSH server hostname
  port: number                              // SSH server port (default: 22)
  username: string                          // SSH username
  authMethod: 'password' | 'privateKey'     // Authentication method
  password?: string                         // Password for password auth
  privateKey?: string                       // Private key content for key auth
  privateKeyPassphrase?: string             // Passphrase if key is encrypted
}

Basic SSH Configuration

const connection = {
  type: DatabaseType.PostgreSQL,
  host: 'internal-db.example.com',
  port: 5432,
  database: 'production',
  username: 'db_user',
  password: 'db_password',
  ssh: {
    enabled: true,
    host: 'bastion.example.com',
    port: 22,
    username: 'ubuntu',
    authMethod: 'password',
    password: 'ssh_password'
  }
}

SSH Tunnel Manager

The SSHTunnelManager handles tunnel lifecycle using the ssh2 library:

Creating Tunnels

import { sshTunnelManager } from '@main/services/ssh-tunnel'

// Create tunnel and get local port
const localPort = await sshTunnelManager.createTunnel(
  sessionId,
  sshConfig,
  remoteHost,    // Database server hostname
  remotePort     // Database server port
)

// Connect to database via tunnel
const dbConnection = {
  host: '127.0.0.1',
  port: localPort,  // Dynamically assigned
  // ... other db config
}

Implementation Details

The SSH tunnel manager:
  1. Creates a local TCP server on 127.0.0.1 with a random available port
  2. Establishes SSH connection to the bastion host using provided credentials
  3. Forwards traffic from the local port to the remote database
  4. Manages tunnel lifecycle tied to the connection session
Each tunnel listens on 127.0.0.1 only and uses a dynamically assigned port to avoid conflicts

Tunnel Lifecycle

class SSHTunnelManager {
  // Create tunnel and return local port
  async createTunnel(
    connectionId: string,
    sshConfig: SSHConfig,
    remoteHost: string,
    remotePort: number
  ): Promise<number>

  // Close specific tunnel
  closeTunnel(connectionId: string): void

  // Check if tunnel exists
  hasTunnel(connectionId: string): boolean

  // Get local port for existing tunnel
  getLocalPort(connectionId: string): number | null

  // Close all tunnels
  closeAllTunnels(): void
}

Connection Flow with SSH

1

Connection Request

User initiates connection with SSH config enabled
2

SSH Tunnel Creation

if (config.ssh?.enabled) {
  const remoteHost = config.host || 'localhost'
  const remotePort = config.port || DEFAULT_PORTS[config.type]
  
  const localPort = await sshTunnelManager.createTunnel(
    sessionId,
    config.ssh,
    remoteHost,
    remotePort
  )
  
  // Update config to use tunnel
  connectionConfig = {
    ...config,
    host: '127.0.0.1',
    port: localPort
  }
}
3

Database Connection

Database driver connects to localhost:<localPort>, traffic is forwarded through SSH tunnel
4

Cleanup on Disconnect

When session ends, both database connection and SSH tunnel are closed

Error Handling

SSH Connection Errors

Common SSH errors and solutions:
Cause: Invalid credentials or private keySolutions:
  • Verify username and password
  • Check private key format (OpenSSH format required)
  • Ensure private key has correct permissions
  • Try private key passphrase if key is encrypted
Cause: Cannot reach SSH serverSolutions:
  • Verify SSH host and port are correct
  • Check firewall rules allow SSH traffic
  • Ensure SSH server is running
  • Try increasing readyTimeout (default: 30s)
Cause: SSH host key changed or unknownSolutions:
  • Remove old host key from ~/.ssh/known_hosts
  • Verify you’re connecting to correct server
  • Check for man-in-the-middle attacks
Cause: Cannot forward to remote databaseSolutions:
  • Verify database host and port are accessible from bastion
  • Check bastion firewall rules
  • Ensure database is running
  • Verify network connectivity between bastion and database

Error Logging

SSH tunnel errors are logged with detailed information:
// SSH connection attempt
logger.info('SSH connecting', {
  host: connectConfig.host,
  port: connectConfig.port,
  username: connectConfig.username,
  authMethod: sshConfig.authMethod,
  hasPassword: !!connectConfig.password,
  hasPrivateKey: !!connectConfig.privateKey
})

// SSH errors
logger.error('SSH connection error:', JSON.stringify(err))

Testing SSH Connections

The connection test verifies both SSH tunnel AND database connection:
const result = await connectionManager.testConnection(config)

interface TestConnectionResult {
  success: boolean
  error?: string
  latency?: number
  serverVersion?: string
  serverInfo?: Record<string, string>
  sshSuccess?: boolean    // true if tunnel created successfully
  sshError?: string       // SSH-specific error message
}
{
  success: true,
  latency: 342,
  serverVersion: 'PostgreSQL 14.5',
  serverInfo: {
    'Encoding': 'UTF8',
    'Timezone': 'UTC',
    'Max Connections': '100'
  },
  sshSuccess: true,
  sshError: null
}

Reconnection with SSH

When a connection is lost, Zequel automatically recreates the SSH tunnel:
async reconnect(id: string): Promise<boolean> {
  // Close old SSH tunnel
  if (sshTunnelManager.hasTunnel(id)) {
    sshTunnelManager.closeTunnel(id)
  }

  // Recreate tunnel with same config
  if (config.ssh?.enabled) {
    const localPort = await sshTunnelManager.createTunnel(
      id,
      config.ssh,
      remoteHost,
      remotePort
    )
    connectionConfig.port = localPort
  }

  // Reconnect database
  await driver.connect(connectionConfig)
}
If reconnection fails during tunnel creation, subsequent attempts will retry the full SSH + database connection flow

Supported Databases

SSH tunneling works with all network-based databases:
  • PostgreSQL ✓
  • MySQL ✓
  • MariaDB ✓
  • SQL Server ✓
  • MongoDB ✓
  • Redis ✓
  • ClickHouse ✓
SQLite and DuckDB are file-based and do not support SSH tunneling

Private Key Formats

Zequel supports OpenSSH private key format:
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Converting PEM to OpenSSH Format

If you have an old PEM format key:
# Convert RSA private key to OpenSSH format
ssh-keygen -p -m OpenSSH -f ~/.ssh/id_rsa

# Generate new key in OpenSSH format
ssh-keygen -t ed25519 -f ~/.ssh/zequel_key

Security Considerations

SSH credentials are stored in Zequel’s encrypted local database. Private keys and passwords are never transmitted except during connection.

Best Practices

  1. Use private key authentication instead of passwords when possible
  2. Encrypt private keys with a strong passphrase
  3. Use dedicated SSH keys for Zequel rather than your personal SSH key
  4. Rotate SSH credentials regularly
  5. Limit bastion host access using firewall rules and security groups
  6. Enable SSH key-based authentication on bastion hosts
  7. Monitor SSH access logs on bastion hosts

Example Scenarios

AWS RDS in Private Subnet

const connection = {
  type: DatabaseType.PostgreSQL,
  host: 'prod-db.abc123.us-east-1.rds.amazonaws.com',
  port: 5432,
  database: 'production',
  username: 'app_user',
  password: 'db_password',
  ssl: true,  // RDS requires SSL
  ssh: {
    enabled: true,
    host: 'ec2-12-34-56-78.compute-1.amazonaws.com',  // Bastion in public subnet
    port: 22,
    username: 'ec2-user',
    authMethod: 'privateKey',
    privateKey: '...'  // EC2 key pair
  }
}

GCP Cloud SQL with IAP Tunnel

const connection = {
  type: DatabaseType.MySQL,
  host: '10.128.0.3',  // Private IP
  port: 3306,
  database: 'app_db',
  username: 'root',
  password: 'password',
  ssh: {
    enabled: true,
    host: 'bastion.example.com',  // Compute Engine instance
    port: 22,
    username: 'sa_...',  // Service account
    authMethod: 'privateKey',
    privateKey: '...'  // Service account key
  }
}

Self-Hosted Database Behind VPN

const connection = {
  type: DatabaseType.PostgreSQL,
  host: '192.168.1.100',  // Internal network IP
  port: 5432,
  database: 'internal_db',
  username: 'admin',
  password: 'secure_password',
  ssh: {
    enabled: true,
    host: 'vpn-gateway.company.com',
    port: 2222,  // Custom SSH port
    username: 'vpnuser',
    authMethod: 'privateKey',
    privateKey: '...',
    privateKeyPassphrase: 'key_password'
  }
}

Troubleshooting

Enable SSH Debug Logging

SSH connection details are logged at info level:
logger.info('SSH connecting', {
  host: 'bastion.example.com',
  port: 22,
  username: 'ubuntu',
  authMethod: 'privateKey',
  hasPassword: false,
  hasPrivateKey: true,
  privateKeyPrefix: '-----BEGIN OPENSSH PRIVATE KEY-----\nb3Bl...'  // First 40 chars
})

logger.info('SSH handshake completed', negotiated)
logger.info('SSH connection established')
logger.info('SSH tunnel ready: localhost:54321 -> db.internal:5432')

Common Issues

Check network connectivity:
# Test SSH connection manually
ssh -v [email protected]

# Test database access from bastion
ssh user@bastion telnet db.internal 5432

Connection Overview

Learn about connection management

SSL/TLS Configuration

Encrypt database connections with SSL/TLS

Build docs developers (and LLMs) love