From 8f35bb7ec8bacd86fc9bbabb1804266059bd3fb7 Mon Sep 17 00:00:00 2001 From: Warezpeddler Date: Thu, 29 Jan 2026 00:03:02 +0000 Subject: [PATCH] Initial code commit --- .gitignore | 57 ++ README.md | 351 +++++++++ config.yaml.example | 56 ++ go.mod | 37 + go.sum | 78 ++ internal/config/config.go | 156 ++++ internal/docker/client/Dockerfile | 35 + internal/docker/client/docker-compose.yml | 24 + internal/docker/client/entrypoint.sh | 386 ++++++++++ internal/docker/manager.go | 166 ++++ internal/providers/hetzner/client.go | 172 +++++ internal/providers/hetzner/server.go | 76 ++ internal/providers/hetzner/ssh_keys.go | 107 +++ internal/providers/letsencrypt/client.go | 216 ++++++ .../providers/letsencrypt/namecheap_dns.go | 94 +++ internal/providers/namecheap/client.go | 567 ++++++++++++++ internal/providers/namecheap/dns.go | 34 + internal/services/definitions.go | 313 ++++++++ internal/services/installer.go | 196 +++++ internal/ssh/keygen.go | 86 +++ internal/ssh/passphrase.go | 68 ++ internal/sslh/config.go | 309 ++++++++ internal/sslh/generator.go | 40 + internal/templates/cloudinit.go | 721 ++++++++++++++++++ internal/wireguard/client.go | 90 +++ internal/wireguard/server.go | 102 +++ pkg/utils/debug_log.go | 48 ++ pkg/utils/dns.go | 131 ++++ pkg/utils/ip.go | 44 ++ pkg/utils/progress.go | 126 +++ pkg/utils/retry.go | 53 ++ pkg/utils/ssh.go | 40 + pkg/utils/validation.go | 34 + scripts/diagnose_container.sh | 177 +++++ scripts/diagnose_deployment.sh | 224 ++++++ scripts/fix_deployment.sh | 414 ++++++++++ scripts/verify_deployment.sh | 210 +++++ sslh | 1 + 38 files changed, 6039 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.yaml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/docker/client/Dockerfile create mode 100644 internal/docker/client/docker-compose.yml create mode 100755 internal/docker/client/entrypoint.sh create mode 100644 internal/docker/manager.go create mode 100644 internal/providers/hetzner/client.go create mode 100644 internal/providers/hetzner/server.go create mode 100644 internal/providers/hetzner/ssh_keys.go create mode 100644 internal/providers/letsencrypt/client.go create mode 100644 internal/providers/letsencrypt/namecheap_dns.go create mode 100644 internal/providers/namecheap/client.go create mode 100644 internal/providers/namecheap/dns.go create mode 100644 internal/services/definitions.go create mode 100644 internal/services/installer.go create mode 100644 internal/ssh/keygen.go create mode 100644 internal/ssh/passphrase.go create mode 100644 internal/sslh/config.go create mode 100644 internal/sslh/generator.go create mode 100644 internal/templates/cloudinit.go create mode 100644 internal/wireguard/client.go create mode 100644 internal/wireguard/server.go create mode 100644 pkg/utils/debug_log.go create mode 100644 pkg/utils/dns.go create mode 100644 pkg/utils/ip.go create mode 100644 pkg/utils/progress.go create mode 100644 pkg/utils/retry.go create mode 100644 pkg/utils/ssh.go create mode 100644 pkg/utils/validation.go create mode 100755 scripts/diagnose_container.sh create mode 100755 scripts/diagnose_deployment.sh create mode 100755 scripts/fix_deployment.sh create mode 100755 scripts/verify_deployment.sh create mode 160000 sslh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f63fdd7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c50a2a --- /dev/null +++ b/README.md @@ -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 --namecheap-key --namecheap-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 + +# Shorthand +sslh-lab verify +# or +sslh-lab vp +``` + +## 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//id_ed25519 @ + +# Copy the fix script from your local project to the VPS +scp -i ~/.sslh-lab/deployments//id_ed25519 scripts/fix_deployment.sh @:/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//id_ed25519 scripts/diagnose_deployment.sh @:/tmp/ + +# SSH and run +ssh -i ~/.sslh-lab/deployments//id_ed25519 @ +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. diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..e4c637f --- /dev/null +++ b/config.yaml.example @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1eb3f9c --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e020461 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..da04112 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/docker/client/Dockerfile b/internal/docker/client/Dockerfile new file mode 100644 index 0000000..984aee5 --- /dev/null +++ b/internal/docker/client/Dockerfile @@ -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"] diff --git a/internal/docker/client/docker-compose.yml b/internal/docker/client/docker-compose.yml new file mode 100644 index 0000000..de13291 --- /dev/null +++ b/internal/docker/client/docker-compose.yml @@ -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 diff --git a/internal/docker/client/entrypoint.sh b/internal/docker/client/entrypoint.sh new file mode 100755 index 0000000..d55efc8 --- /dev/null +++ b/internal/docker/client/entrypoint.sh @@ -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 </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' + + + + Lab Admin Panel + + + + +
+

Lab Admin Panel

+
+

System Information

+

Server: SSLH Multiplex Lab - Client Container

+

Access: Localhost only (via reverse SSH tunnel)

+

Purpose: Lateral movement demonstration

+
+
+

Available Resources

+

This server is accessible only through the reverse SSH tunnel established from the container.

+ View Secrets File +
+
+

Lateral Movement Demo

+

This demonstrates accessing container resources from a compromised VPS:

+
    +
  1. Establish reverse tunnel: ssh -R 127.0.0.1:2222:127.0.0.1:8888 -o ExitOnForwardFailure=yes testuser@ssh.domain.com -p 443
  2. +
  3. From VPS, access: curl http://localhost:2222/secrets.txt
  4. +
  5. Or browse: curl http://localhost:2222/
  6. +
+
+
+ + +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 < -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 "$@" diff --git a/internal/docker/manager.go b/internal/docker/manager.go new file mode 100644 index 0000000..4d7b0ee --- /dev/null +++ b/internal/docker/manager.go @@ -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 +} diff --git a/internal/providers/hetzner/client.go b/internal/providers/hetzner/client.go new file mode 100644 index 0000000..4d1d025 --- /dev/null +++ b/internal/providers/hetzner/client.go @@ -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 +} diff --git a/internal/providers/hetzner/server.go b/internal/providers/hetzner/server.go new file mode 100644 index 0000000..84a3a29 --- /dev/null +++ b/internal/providers/hetzner/server.go @@ -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 +} diff --git a/internal/providers/hetzner/ssh_keys.go b/internal/providers/hetzner/ssh_keys.go new file mode 100644 index 0000000..5707f9d --- /dev/null +++ b/internal/providers/hetzner/ssh_keys.go @@ -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 +} diff --git a/internal/providers/letsencrypt/client.go b/internal/providers/letsencrypt/client.go new file mode 100644 index 0000000..a98b06d --- /dev/null +++ b/internal/providers/letsencrypt/client.go @@ -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, ®); 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 +} diff --git a/internal/providers/letsencrypt/namecheap_dns.go b/internal/providers/letsencrypt/namecheap_dns.go new file mode 100644 index 0000000..9cfff9c --- /dev/null +++ b/internal/providers/letsencrypt/namecheap_dns.go @@ -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 +} diff --git a/internal/providers/namecheap/client.go b/internal/providers/namecheap/client.go new file mode 100644 index 0000000..d64cd56 --- /dev/null +++ b/internal/providers/namecheap/client.go @@ -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 +} diff --git a/internal/providers/namecheap/dns.go b/internal/providers/namecheap/dns.go new file mode 100644 index 0000000..286f4d0 --- /dev/null +++ b/internal/providers/namecheap/dns.go @@ -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 + } + } +} diff --git a/internal/services/definitions.go b/internal/services/definitions.go new file mode 100644 index 0000000..d74e164 --- /dev/null +++ b/internal/services/definitions.go @@ -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) +} diff --git a/internal/services/installer.go b/internal/services/installer.go new file mode 100644 index 0000000..9827211 --- /dev/null +++ b/internal/services/installer.go @@ -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' + + + + Demo App Page + + + + +
+

Demo app page

+
+ + +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) +} diff --git a/internal/ssh/keygen.go b/internal/ssh/keygen.go new file mode 100644 index 0000000..7a6b031 --- /dev/null +++ b/internal/ssh/keygen.go @@ -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 +} diff --git a/internal/ssh/passphrase.go b/internal/ssh/passphrase.go new file mode 100644 index 0000000..9aaedc6 --- /dev/null +++ b/internal/ssh/passphrase.go @@ -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 +} diff --git a/internal/sslh/config.go b/internal/sslh/config.go new file mode 100644 index 0000000..78a6f68 --- /dev/null +++ b/internal/sslh/config.go @@ -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() +} diff --git a/internal/sslh/generator.go b/internal/sslh/generator.go new file mode 100644 index 0000000..a1bf029 --- /dev/null +++ b/internal/sslh/generator.go @@ -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 +} diff --git a/internal/templates/cloudinit.go b/internal/templates/cloudinit.go new file mode 100644 index 0000000..2a47e64 --- /dev/null +++ b/internal/templates/cloudinit.go @@ -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 < /etc/apt/apt.conf.d/50unattended-upgrades </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'", + "Demo

Demo app page

", + "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 </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/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/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 ` + + + Demo App Page + + + + +
+

Demo app page

+
+ + +` +} diff --git a/internal/wireguard/client.go b/internal/wireguard/client.go new file mode 100644 index 0000000..9be082c --- /dev/null +++ b/internal/wireguard/client.go @@ -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 +} diff --git a/internal/wireguard/server.go b/internal/wireguard/server.go new file mode 100644 index 0000000..8a97f36 --- /dev/null +++ b/internal/wireguard/server.go @@ -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() +} diff --git a/pkg/utils/debug_log.go b/pkg/utils/debug_log.go new file mode 100644 index 0000000..978e8e7 --- /dev/null +++ b/pkg/utils/debug_log.go @@ -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") +} diff --git a/pkg/utils/dns.go b/pkg/utils/dns.go new file mode 100644 index 0000000..edae6b8 --- /dev/null +++ b/pkg/utils/dns.go @@ -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 + } + } +} diff --git a/pkg/utils/ip.go b/pkg/utils/ip.go new file mode 100644 index 0000000..9d29da9 --- /dev/null +++ b/pkg/utils/ip.go @@ -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") +} diff --git a/pkg/utils/progress.go b/pkg/utils/progress.go new file mode 100644 index 0000000..38b6c69 --- /dev/null +++ b/pkg/utils/progress.go @@ -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) +} diff --git a/pkg/utils/retry.go b/pkg/utils/retry.go new file mode 100644 index 0000000..fafba32 --- /dev/null +++ b/pkg/utils/retry.go @@ -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) +} diff --git a/pkg/utils/ssh.go b/pkg/utils/ssh.go new file mode 100644 index 0000000..7606cf6 --- /dev/null +++ b/pkg/utils/ssh.go @@ -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 +} diff --git a/pkg/utils/validation.go b/pkg/utils/validation.go new file mode 100644 index 0000000..5542c82 --- /dev/null +++ b/pkg/utils/validation.go @@ -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 +} diff --git a/scripts/diagnose_container.sh b/scripts/diagnose_container.sh new file mode 100755 index 0000000..561588e --- /dev/null +++ b/scripts/diagnose_container.sh @@ -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/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/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/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/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/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 "==========================================" diff --git a/scripts/diagnose_deployment.sh b/scripts/diagnose_deployment.sh new file mode 100755 index 0000000..7641cb9 --- /dev/null +++ b/scripts/diagnose_deployment.sh @@ -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/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 "==========================================" diff --git a/scripts/fix_deployment.sh b/scripts/fix_deployment.sh new file mode 100755 index 0000000..1bf8eed --- /dev/null +++ b/scripts/fix_deployment.sh @@ -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' + + + + Demo App Page + + + + +
+

Demo app page

+
+ + +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/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 </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 "" diff --git a/scripts/verify_deployment.sh b/scripts/verify_deployment.sh new file mode 100755 index 0000000..e3edf8c --- /dev/null +++ b/scripts/verify_deployment.sh @@ -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/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 "==========================================" diff --git a/sslh b/sslh new file mode 160000 index 0000000..86188cd --- /dev/null +++ b/sslh @@ -0,0 +1 @@ +Subproject commit 86188cdd284932e79bfc8929fc595023bcb01d4d