A secure SSH tunneling server that provides instant HTTPS URLs for your local services. Connect via SSH and get a public subdomain that routes traffic to your local development server.
# Connect with SSH key (username must be "tunnel")
ssh -R 0:localhost:3000 tunnel@your-server.com -p 2222 -i ~/.ssh/id_rsa
# OR connect with username/password (created via WebUI)
ssh -R 0:localhost:3000 username@your-server.com -p 2222
# Your service is now accessible at the provided URL:
# Tunnel created: http://happy-cloud-42.your-server.comNote: Server must be publicly accessible to share tunnels. Authentication requires either SSH keys or WebUI-created credentials.
- Docker and Docker Compose
- For production: Public server, domain with wildcard DNS (*.domain.com), open ports 2222/80/443
# 1. Setup
git clone https://github.com/your-repo/bohrer-go.git && cd bohrer-go
cp .env.example .env
# Edit .env: Set DOMAIN, ACME_EMAIL, etc.
# 2. Start server
docker compose up -d
# 3. Get admin credentials and access WebUI
docker compose logs ssh-tunnel
# Access: https://localhost or https://your-domain.com
# 4. Create authentication (in WebUI)
# - Users: "Manage Users" → Create username/password
# - SSH Keys: "SSH Keys" → Add public keys
# 5. Test tunnel
ssh -R 0:localhost:3000 user@your-domain.com -p 2222┌─────────────────┐ SSH Tunnel ┌──────────────────┐ HTTP Proxy ┌─────────────────┐
│ Your Local │ ───────────────▶ │ Bohrer Server │ ───────────────▶ │ Internet │
│ Service │ :2222 │ + WebUI │ :80 │ Users │
│ localhost:3000│ ◀─────────────── │ your-server.com │ ◀────────────── │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
▼
Subdomain Generator
happy-cloud-42.your-server.com
- SSH Connection: You connect via SSH with remote port forwarding (
-R) - Subdomain Generation: Server creates a unique subdomain (e.g.,
happy-cloud-42.your-server.com) - HTTP Proxy: Server routes HTTP requests from the subdomain to your SSH tunnel
- Secure Access: Your local service becomes accessible via the public subdomain
- WebUI Management: Visit the root domain to manage tunnels and users
| Environment | Requirements | Access |
|---|---|---|
| Local Development | localhost domain, Docker |
Same machine only |
| Production | Public IP, Domain with wildcard DNS (*.domain.com), Ports 2222/80/443 open | Internet accessible |
Access at https://your-domain.com (or https://localhost for development)
Features:
- 📊 Dashboard: View active tunnels with real-time updates
- 👥 User Management: Create/delete SSH users with passwords
- 🔑 SSH Key Management: Add/remove SSH public keys
- 🔒 Authentication: Basic auth with auto-generated or configured admin credentials
Create users via WebUI (https://your-domain.com) → "Manage Users"
Via WebUI (Recommended):
- Go to "SSH Keys" in WebUI
- Add your public key with a name
- Connect using username "tunnel":
ssh -R 0:localhost:3000 tunnel@your-server.com -p 2222
Via Command Line:
# Add key to authorized_keys
docker compose run --rm ssh-tunnel sh -c "mkdir -p /data && echo '$(cat ~/.ssh/id_rsa.pub)' > /data/authorized_keys"# Basic tunnel
ssh -R 0:localhost:3000 user@your-server.com -p 2222Works with any HTTP service: React, Flask, Django, static files, etc.
| Variable | Default | Description |
|---|---|---|
DOMAIN |
localhost |
Base domain for tunnel subdomains (e.g., subdomain.DOMAIN) |
LOG_LEVEL |
INFO |
Log level: DEBUG, INFO, WARN, ERROR, FATAL |
| Variable | Default | Description |
|---|---|---|
ACME_EMAIL |
test@example.com |
Email for Let's Encrypt registration (empty = use self-signed) |
ACME_STAGING |
true |
Use Let's Encrypt staging (true) or production (false) - SAFETY: Always defaults to staging |
ACME_DIRECTORY_URL |
"" |
Custom ACME server URL (empty = Let's Encrypt) |
ACME_FORCE_LOCAL |
false |
Force ACME even for local domains (for custom PKI) |
ACME_CERT_PATH |
/data/certs/fullchain.pem |
Certificate file path (inside container) |
ACME_KEY_PATH |
/data/certs/key.pem |
Private key file path (inside container) |
ACME_CHALLENGE_DIR |
/data/acme-challenge |
HTTP-01 challenge directory |
ACME_RENEWAL_DAYS |
30 |
Certificate renewal threshold (days before expiry) |
SKIP_ACME |
false |
Skip ACME entirely and use self-signed certificates |
| Variable | Default | Description |
|---|---|---|
SSH_PORT |
22 |
SSH server internal port (inside container) |
HTTP_PORT |
80 |
HTTP proxy internal port (inside container) |
HTTPS_PORT |
443 |
HTTPS proxy internal port (inside container) |
SSH_EXTERNAL_PORT |
SSH_PORT |
SSH server external port (Docker host) |
HTTP_EXTERNAL_PORT |
HTTP_PORT |
HTTP proxy external port (Docker host) |
HTTPS_EXTERNAL_PORT |
HTTPS_PORT |
HTTPS proxy external port (Docker host) |
| Variable | Default | Description |
|---|---|---|
SSH_AUTHORIZED_KEYS |
/data/authorized_keys |
Path to SSH authorized keys file |
| Variable | Default | Description |
|---|---|---|
USER_STORAGE_TYPE |
file |
Storage backend: "file" (persistent) or "memory" (temporary) |
USER_STORAGE_PATH |
/data/users.json |
Path to user storage file (when using file backend) |
| Variable | Default | Description |
|---|---|---|
WEBUI_USERNAME |
(empty) | WebUI admin username (empty = auto-generate) |
WEBUI_PASSWORD |
(empty) | WebUI admin password (empty = auto-generate) |
| Environment | Configuration | Result |
|---|---|---|
| Development | DOMAIN=localhost, no ACME_EMAIL |
Self-signed certificate |
| Production | Valid domain, ACME_EMAIL, ACME_STAGING=false |
Let's Encrypt certificate |
| Testing | Valid domain, ACME_EMAIL, ACME_STAGING=true |
Let's Encrypt staging |
| Internal CA | ACME_DIRECTORY_URL, ACME_FORCE_LOCAL=true |
Custom ACME server |
The server chooses certificate type based on this priority:
- No ACME Email → Always use self-signed certificates
- Local Domain + No Custom ACME URL → Use self-signed certificates
- Local Domain + Custom ACME URL → Use custom ACME server
- Public Domain → Use Let's Encrypt (staging or production)
- ACME_FORCE_LOCAL=true → Force ACME even for local domains
Local domains are detected as: localhost, *.local, *.lan, *.home, *.internal, *.dev, *.test, or private IP addresses.
The server includes built-in rate limiting to protect against Let's Encrypt production rate limits:
- Authorization Failures: 5 per hostname per hour
- New Orders: 300 per account every 3 hours
- Domain Certificates: 50 per domain every 7 days
- Automatic Protection: Checks limits before making ACME requests
- Staging Bypass: No limits applied for staging environment
- Custom ACME Bypass: No limits for custom ACME directories
- Error Prevention: Clear error messages when limits would be exceeded
- Memory-based: Tracks usage in application memory (resets on restart)
The rate limiting status can be monitored programmatically through the ACME client's GetRateLimitStatus() method, which returns current usage for all tracked limits.
For production deployment, configure DNS:
# A record
your-domain.com. IN A YOUR_SERVER_IP
# Wildcard for subdomains
*.your-domain.com. IN A YOUR_SERVER_IP
- Docker and Docker Compose (the project is supposed to run in containers)
- Go 1.24 or later (optional - only if you want to run tests locally)
- The Makefile supports both Docker-based and local Go testing
# Unit tests with coverage (can run locally with Go 1.24+)
make test
# Run tests for specific package
make test-ssh # SSH package tests
make test-proxy # Proxy package tests
make test-webui # WebUI package tests
# End-to-end tests (requires Docker)
make e2e
# Generate coverage report
make coverage # Generates coverage.html
# Start development environment
make dev-up
# View logs
docker compose logs ssh-tunnelNote: End-to-end tests automatically generate temporary SSH keys for testing and clean them up afterward. No credentials are stored in the repository.
├── cmd/server/ # Main application
├── internal/
│ ├── acme/ # ACME/Let's Encrypt integration
│ ├── certs/ # Certificate generation utilities
│ ├── common/ # Shared utilities (mutex, URL builder, cert validator)
│ ├── config/ # Configuration management
│ ├── fileutil/ # File operations with atomic writes
│ ├── logger/ # Structured logging
│ ├── proxy/ # HTTP/HTTPS reverse proxy
│ ├── ssh/ # SSH server implementation
│ ├── testutil/ # Test utilities and shared mocks
│ └── webui/ # Web UI and user management
├── test/ # Test utilities and scripts
├── docker-compose.yml # Development environment
└── Makefile # Build and test automation
"Connection refused" on SSH:
# Check if server is running
nc -zv your-server.com 2222
# Check server logs
docker compose logs ssh-tunnel"Permission denied" on SSH:
- For password auth: Ensure the username exists in WebUI and password is correct
- Check WebUI at
https://your-server.com→ "Manage Users" - Verify the username was created successfully
- Try creating a new user if needed
- Check WebUI at
- For key auth: Check these common issues:
# Verify your public key is in authorized_keys inside the container docker compose exec ssh-tunnel cat /data/authorized_keys # Check file permissions inside container docker compose exec ssh-tunnel ls -la /data/authorized_keys # Test SSH key locally ssh-keygen -l -f ~/.ssh/id_rsa.pub # Check server logs for specific auth errors docker compose logs ssh-tunnel | grep -i auth # Verify the authorized_keys file exists in the volume docker compose exec ssh-tunnel ls -la /data/
SSH Key Authentication Issues:
- Key not found: Ensure
SSH_AUTHORIZED_KEYSpath is correct - Permission errors: Check that the container can read the authorized_keys file
- Wrong key format: Verify your public key is in the correct OpenSSH format
- Multiple keys: Add one key per line in the authorized_keys file
- Path issues: Make sure the Docker volume mounts the correct directory
Tunnel created but URL not accessible:
# Check if HTTP proxy is running
curl -I http://your-server.com
# Test with subdomain
curl -H "Host: happy-cloud-42.your-server.com" http://your-server.comLocal service not responding:
- Verify your local service is running and accessible
- Check the port number matches what you specified in SSH command
- Test local service directly:
curl localhost:YOUR_PORT
Server won't start:
# Check port conflicts
netstat -tlnp | grep :2222
netstat -tlnp | grep :80
# Check logs for specific errors
docker compose logs ssh-tunnel- Tests first: Write tests before implementing features
- Docker-based: All development happens in containers
- High coverage: Maintain >85% test coverage
- TDD approach: Red → Green → Refactor
# Unit tests
make test
# Integration tests
make e2e
# Check coverage
open coverage.htmlMIT License - see LICENSE file for details.