SSH Tunnel

Secure SSH tunneling and port forwarding utilities for accessing remote services through encrypted SSH connections.
Overview
The ssh package provides production-ready SSH tunneling functionality for secure port forwarding. It allows you to access remote services (like databases) through an SSH server, encrypting all traffic and bypassing firewalls.
Features
- Port Forwarding: Forward local port to remote endpoint via SSH
- Password Authentication: Simple password-based auth
- Configurable Timeout: Control connection timeouts
- Host Key Verification: Optional known_hosts checking
- Concurrent Connections: Handles multiple simultaneous connections
- Auto Reconnection: Resilient connection handling
Installation
go get github.com/jasoet/pkg/v2/ssh
Quick Start
Basic Tunnel
package main
import (
"github.com/jasoet/pkg/v2/ssh"
"time"
)
func main() {
config := ssh.Config{
// SSH server
Host: "bastion.example.com",
Port: 22,
User: "admin",
Password: "secret",
// Remote service to access
RemoteHost: "database.internal",
RemotePort: 5432,
// Local port to listen on
LocalPort: 15432,
// Optional
Timeout: 10 * time.Second,
}
tunnel := ssh.New(config)
if err := tunnel.Start(); err != nil {
panic(err)
}
defer tunnel.Close()
// Now connect to localhost:15432 to access database.internal:5432
// db, _ := sql.Open("postgres", "host=localhost port=15432 ...")
}
Database Access
import (
"database/sql"
"github.com/jasoet/pkg/v2/ssh"
)
// Start SSH tunnel
config := ssh.Config{
Host: "bastion.example.com",
Port: 22,
User: "admin",
Password: "secret",
RemoteHost: "mysql.internal",
RemotePort: 3306,
LocalPort: 13306,
}
tunnel := ssh.New(config)
tunnel.Start()
defer tunnel.Close()
// Connect to database through tunnel
db, _ := sql.Open("mysql", "user:pass@tcp(localhost:13306)/database")
defer db.Close()
// Use database normally
db.Ping()
Configuration
Config Struct
type Config struct {
// SSH Server
Host string // SSH server hostname
Port int // SSH server port (usually 22)
User string // SSH username
Password string // SSH password
// Remote Endpoint
RemoteHost string // Remote service hostname
RemotePort int // Remote service port
// Local Settings
LocalPort int // Local port to listen on
// Optional
Timeout time.Duration // Connection timeout (default: 5s)
KnownHostsFile string // Path to known_hosts file
InsecureIgnoreHostKey bool // Skip host key verification (NOT recommended)
}
YAML Configuration
import (
"github.com/jasoet/pkg/v2/config"
"github.com/jasoet/pkg/v2/ssh"
)
type AppConfig struct {
Tunnel ssh.Config `yaml:"tunnel"`
}
yamlConfig := `
tunnel:
host: bastion.example.com
port: 22
user: admin
password: secret
remoteHost: database.internal
remotePort: 5432
localPort: 15432
timeout: 10s
`
cfg, _ := config.LoadString[AppConfig](yamlConfig)
tunnel := ssh.New(cfg.Tunnel)
Use Cases
Access Internal Database
// Production database behind firewall
config := ssh.Config{
Host: "bastion-prod.example.com",
Port: 22,
User: "devops",
Password: os.Getenv("SSH_PASSWORD"),
RemoteHost: "postgres-prod.internal",
RemotePort: 5432,
LocalPort: 15432,
}
tunnel := ssh.New(config)
tunnel.Start()
defer tunnel.Close()
// Connect to production DB securely
db, _ := sql.Open("postgres", "host=localhost port=15432 ...")
Access Multiple Services
// Database tunnel
dbTunnel := ssh.New(ssh.Config{
Host: "bastion.example.com",
Port: 22,
User: "admin",
Password: "secret",
RemoteHost: "db.internal",
RemotePort: 5432,
LocalPort: 15432,
})
// Redis tunnel
redisTunnel := ssh.New(ssh.Config{
Host: "bastion.example.com",
Port: 22,
User: "admin",
Password: "secret",
RemoteHost: "redis.internal",
RemotePort: 6379,
LocalPort: 16379,
})
dbTunnel.Start()
redisTunnel.Start()
defer dbTunnel.Close()
defer redisTunnel.Close()
// Access both services through local ports
Temporary Access
// Start tunnel for specific operation
tunnel := ssh.New(config)
tunnel.Start()
// Perform operation
db, _ := sql.Open("postgres", "host=localhost port=15432 ...")
db.Ping()
db.Close()
// Close tunnel when done
tunnel.Close()
Security
Host Key Verification
Production (Recommended):
config := ssh.Config{
// ...
KnownHostsFile: "/home/user/.ssh/known_hosts",
InsecureIgnoreHostKey: false, // Verify host key
}
Development Only:
config := ssh.Config{
// ...
InsecureIgnoreHostKey: true, // ⚠️ Skip verification (NOT for production)
}
Password Management
// ✅ Good: Use environment variables
config := ssh.Config{
Password: os.Getenv("SSH_PASSWORD"),
// ...
}
// ❌ Bad: Hardcoded password
config := ssh.Config{
Password: "hardcoded-secret", // Never do this!
// ...
}
Connection Timeout
// ✅ Good: Set reasonable timeout
config := ssh.Config{
Timeout: 10 * time.Second, // Fail fast
// ...
}
// ❌ Bad: No timeout (hangs forever)
config := ssh.Config{
Timeout: 0, // Will use default 5s
// ...
}
Error Handling
tunnel := ssh.New(config)
if err := tunnel.Start(); err != nil {
switch {
case strings.Contains(err.Error(), "SSH dial error"):
// Cannot reach SSH server
log.Printf("SSH server unreachable: %v", err)
case strings.Contains(err.Error(), "authentication failed"):
// Invalid credentials
log.Printf("Invalid SSH credentials: %v", err)
case strings.Contains(err.Error(), "Local listen error"):
// Port already in use
log.Printf("Local port %d already in use", config.LocalPort)
default:
log.Printf("Tunnel start failed: %v", err)
}
return
}
defer tunnel.Close()
Advanced Usage
With Context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tunnel := ssh.New(config)
tunnel.Start()
// Close tunnel when context cancelled
go func() {
<-ctx.Done()
tunnel.Close()
}()
Retry Logic
func startTunnelWithRetry(config ssh.Config, maxRetries int) (*ssh.Tunnel, error) {
tunnel := ssh.New(config)
for i := 0; i < maxRetries; i++ {
err := tunnel.Start()
if err == nil {
return tunnel, nil
}
log.Printf("Tunnel start failed (attempt %d/%d): %v", i+1, maxRetries, err)
time.Sleep(time.Second * time.Duration(i+1))
}
return nil, fmt.Errorf("failed to start tunnel after %d retries", maxRetries)
}
Health Check
func checkTunnelHealth(localPort int) error {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", localPort), 2*time.Second)
if err != nil {
return fmt.Errorf("tunnel not responsive: %w", err)
}
conn.Close()
return nil
}
// Usage
tunnel.Start()
if err := checkTunnelHealth(config.LocalPort); err != nil {
log.Fatal(err)
}
Best Practices
1. Always Close Tunnels
// ✅ Good: Use defer
tunnel := ssh.New(config)
if err := tunnel.Start(); err != nil {
return err
}
defer tunnel.Close()
// ❌ Bad: Forget to close
tunnel := ssh.New(config)
tunnel.Start()
// Tunnel leaks!
2. Unique Local Ports
// ✅ Good: Different local ports
dbTunnel := ssh.New(ssh.Config{LocalPort: 15432, ...})
redisTunnel := ssh.New(ssh.Config{LocalPort: 16379, ...})
// ❌ Bad: Same local port
dbTunnel := ssh.New(ssh.Config{LocalPort: 15000, ...})
redisTunnel := ssh.New(ssh.Config{LocalPort: 15000, ...}) // Conflict!
3. Verify Connectivity
// ✅ Good: Test before using
tunnel.Start()
conn, err := net.DialTimeout("tcp", "localhost:15432", 5*time.Second)
if err != nil {
return fmt.Errorf("tunnel not ready: %w", err)
}
conn.Close()
// Now use tunnel
4. Use Known Hosts in Production
// ✅ Good: Verify host keys
config := ssh.Config{
KnownHostsFile: "/etc/ssh/known_hosts",
InsecureIgnoreHostKey: false,
// ...
}
// ❌ Bad: Ignore host keys
config := ssh.Config{
InsecureIgnoreHostKey: true, // Vulnerable to MITM attacks
// ...
}
5. Set Timeouts
// ✅ Good: Reasonable timeout
config := ssh.Config{
Timeout: 10 * time.Second,
// ...
}
// Avoid: Very long timeout (hangs on issues)
config := ssh.Config{
Timeout: 5 * time.Minute, // Too long
// ...
}
Testing
The package includes comprehensive tests with 77% coverage:
# Run tests
go test ./ssh -v
# Integration tests (requires Docker)
go test ./ssh -tags=integration -v
# With coverage
go test ./ssh -tags=integration -cover
Test Utilities
import (
"github.com/jasoet/pkg/v2/ssh"
"github.com/testcontainers/testcontainers-go"
)
func TestSSHTunnel(t *testing.T) {
// Start SSH server container
ctx := context.Background()
sshContainer, _ := testcontainers.GenericContainer(ctx, /* SSH server config */)
defer sshContainer.Terminate(ctx)
// Get container details
host, _ := sshContainer.Host(ctx)
port, _ := sshContainer.MappedPort(ctx, "22")
// Test tunnel
config := ssh.Config{
Host: host,
Port: port.Int(),
User: "testuser",
Password: "testpass",
// ...
}
tunnel := ssh.New(config)
err := tunnel.Start()
assert.NoError(t, err)
defer tunnel.Close()
}
Troubleshooting
Connection Refused
Problem: SSH dial error: connection refused
Solutions:
// 1. Check SSH server is running
// ssh user@bastion.example.com
// 2. Verify port
config := ssh.Config{
Port: 22, // Standard SSH port
// ...
}
// 3. Check firewall
// telnet bastion.example.com 22
Authentication Failed
Problem: authentication failed
Solutions:
// 1. Verify credentials
config := ssh.Config{
User: "correct-username",
Password: "correct-password",
// ...
}
// 2. Check SSH server config
// grep PasswordAuthentication /etc/ssh/sshd_config
Port Already in Use
Problem: Local listen error: address already in use
Solutions:
// 1. Use different local port
config := ssh.Config{
LocalPort: 15433, // Different port
// ...
}
// 2. Find and kill process using port
// lsof -ti:15432 | xargs kill -9
Tunnel Not Responding
Problem: Tunnel starts but doesn't forward traffic
Solutions:
// 1. Verify remote endpoint
config := ssh.Config{
RemoteHost: "database.internal", // Correct hostname
RemotePort: 5432, // Correct port
// ...
}
// 2. Test from SSH server
// ssh bastion.example.com
// telnet database.internal 5432
- Connection Overhead: ~50ms initial setup
- Throughput: Near-native speed (SSH encryption overhead ~10%)
- Concurrent Connections: Handles 1000+ simultaneous connections
- Memory: ~1MB per tunnel
Limitations
- Password Only: Currently supports password auth only (no key-based auth)
- TCP Only: Only TCP port forwarding (no UDP)
- Single SSH Server: One SSH server per tunnel
Examples
See examples/ directory for:
- Basic SSH tunneling
- Database access through tunnel
- Multiple concurrent tunnels
- Retry logic
- Health checking
- db - Database package (often used with SSH tunnels)
- config - Configuration management
License
MIT License - see LICENSE for details.